Skip to content

Repository files navigation

Balcão AI — Backlog, System Design & Schema

Documento vivo. O cérebro do projecto — orientado a fluxo, jornada do utilizador e propriedade de dados. Tudo parte daqui.


1. O Que é o Balcão AI e Por Que Existe

O Balcão AI nasceu para resolver um problema que qualquer angolano adulto já viveu: ir ao balcão com a documentação errada e voltar para casa sem resolver nada.

O SEPE existe. O SIAC existe. O portal do SME foi relançado em 2026. O problema não é falta de serviços — é que nenhum desses canais alcança o cidadão no momento em que ele precisa, no canal que ele usa, com a informação exacta de que ele necessita.

O cidadão agrega informação de fontes dispersas — amigos, Google, PDFs antigos, boca-a-boca — e vai ao balcão com o que acha que é suficiente. Um detalhe em falta reinicia tudo. Às vezes duas, três vezes para o mesmo serviço. O resultado é desistência estrutural: o BI que fica caducado, o NIF que não é impresso, o certificado de enfermagem que não chega à Ordem porque a sequência INAAREES → autenticação não está documentada em nenhum lugar único.

O Balcão AI é um assistente de serviços públicos via WhatsApp O cidadão escreve o que precisa em linguagem natural. O sistema identifica o serviço exacto, devolve a lista completa de documentos ("NIF impresso, não digital"), o local, o horário, o custo actualizado e — se for um processo multi-etapa a sequência completa ordenada.

O que nos diferencia:

  • Canal WhatsApp — sem registo, sem app, sem literacia digital avançada
  • Cobertura multi-ministério com sequências completas (emigração, ordens profissionais, abertura de empresa)
  • Detalhes de formato que nenhum portal documenta — os "gotchas" do balcão
  • Analytics em tempo real para o Estado — o que os cidadãos mais precisam, por região
  • Arquitectura de API aberta para integração com bancos, universidades e RH

O nome: Balcão é o ponto de atendimento. AI é o que o torna disponível 24/7, sem fila, sem deslocação prévia.


2. Os Três Actores

Actor Quem é O que quer
Cidadão Qualquer pessoa com WhatsApp — sem literacia digital avançada Saber o que levar, onde ir, quanto custa, em que ordem — antes de sair de casa
Operador A equipa do Balcão AI (staff interno) Gerir o catálogo de serviços, actualizar documentos, acompanhar analytics, moderar conteúdo
Parceiro API Banco, universidade, escritório de contabilidade, empresa de RH Integrar o guia de serviços no seu próprio produto (onboarding, portal do aluno, etc.)

Na fase 1, o cidadão interage via WhatsApp (sem conta própria no sistema). O operador gere o catálogo no painel admin. O parceiro API é fase 2 — preparado no schema, bloqueado na UI.


3. Módulos do Sistema

Módulo Responsabilidade
Identity Auth de operadores e parceiros API — cidadão não precisa de conta
Catalogue Ministérios, serviços, documentos, locais, horários, custos
Flows FSM por serviço — sequências multi-etapa, dependências entre serviços
Conversations Sessões WhatsApp — histórico, estado da conversa, FSM activo
NLU Classificação de intenção — mapear mensagem do cidadão para serviço exacto
Locations Balcões, postos e repartições por província/município — horários e estado
Analytics Sessões, serviços mais pedidos, drop-off, pesquisas sem resultado
Notifications Alertas internos para operadores — conteúdo desactualizado, volume anómalo
Webhooks Integração WhatsApp Business API e parceiros externos
API Partners Chaves de API, quotas, logs de uso — para integração sector privado (fase 2)

4. Jornadas do Utilizador


4.1 Jornada do Cidadão — Serviço Simples

[1] Gatilho de vida (BI caducou / vai abrir conta / quer passaporte)
        ↓
[2] Escreve no WhatsApp do Balcão AI em linguagem natural
    "preciso renovar o bi" / "como tiro o nif" / "quero passaporte"
        ↓
[3] NLU classifica a intenção → mapeia para serviço exacto
        ↓
[4] Sistema verifica se há ambiguidade
    a. Clara → responde directamente
    b. Ambígua → faz uma pergunta de clarificação ("É para renovação ou 1ª emissão?")
        ↓
[5] Balcão AI responde com:
    → Lista completa de documentos (com detalhes de formato)
    → Local mais próximo (baseado em província/município declarado ou perguntado)
    → Horário de atendimento actualizado
    → Custo total estimado
    → Prazo estimado de processamento
        ↓
[6] Conversa fica registada (ConversationLog) — anonimizada
        ↓
[7] Cidadão vai ao balcão preparado — na primeira vez

Regras desta jornada:

  • Cidadão não precisa de criar conta, instalar nada, nem saber qual ministério.
  • Primeira resposta útil em ≤ 2 trocas de mensagem.
  • Se o sistema não souber, diz que não sabe — não inventa.
  • Conversas são anonimizadas por defeito. Nenhum dado pessoal é armazenado sem consentimento explícito.

4.2 Jornada do Cidadão — Fluxo Multi-Etapa

[1] Intenção complexa detectada
    "quero emigrar para portugal" / "acabei enfermagem e quero exercer"
        ↓
[2] Sistema identifica que é um Flow multi-etapa
        ↓
[3] Balcão AI apresenta a sequência completa numerada:
    PASSO 1 — INAAREES: Homologação do certificado (2–4 semanas)
    PASSO 2 — MINJUSDH: Autenticação notarial do certificado homologado
    PASSO 3 — Ordem dos Enfermeiros: Inscrição com documentação completa
        ↓
[4] Cidadão pode pedir detalhe de qualquer passo:
    "explica o passo 1" → sistema entra no FlowStep detalhado
        ↓
[5] Cada passo mostra documentos, local, custo, prazo
        ↓
[6] Flow completo fica em cache de sessão (30 min) — cidadão pode navegar entre passos

4.3 Jornada do Operador — Gerir Catálogo

[1] Abre painel de operador
        ↓
[2] Navega até Ministério → Serviço
        ↓
[3] Actualiza lista de documentos, custos, horários
    (ex: adicionar nota "NIF deve ser impresso, versão digital não é aceite")
        ↓
[4] Adiciona/edita Gotcha (detalhe obscuro que causa rejeição no balcão)
        ↓
[5] Publica → versão anterior arquivada (histórico de versões)
        ↓
[6] Sistema marca serviço como "revisto em [data]" — visível no response ao cidadão

4.4 Jornada do Operador — Analytics

[1] Abre dashboard de analytics
        ↓
[2] Vê:
    → Top serviços por volume (últimas 24h / 7d / 30d)
    → Serviços com maior taxa de conversa abandono (drop-off)
    → Pesquisas sem resultado — o que o cidadão pede que não existe no catálogo
    → Volume por província/município
    → Horários de pico de utilização
        ↓
[3] Drill-down num serviço → vê mensagens reais anonimizadas que levaram ao drop-off
        ↓
[4] Exporta relatório para MINTTICS / parceiro Estado

4.5 Jornada do Parceiro API — Integração (Fase 2)

[1] Parceiro (banco, universidade) regista-se como API Partner
        ↓
[2] Recebe API key com quota definida
        ↓
[3] Chama endpoint: GET /api/v1/services/{serviceSlug}
    → Recebe JSON com documentos, locais, custos, gotchas
        ↓
[4] Integra no seu produto:
    "Para abrir conta precisa de NIF impresso — quer que o ajudemos a obter?"
        ↓
[5] Cada chamada à API fica registada em ApiUsageLog
        ↓
[6] Parceiro vê o seu uso no portal de parceiro

5. Entidades — O Que Existe no Sistema


5.1 Identity — Operadores e Parceiros

operator_users — equipa interna do Balcão AI. Isolamento total, auth separada do resto do sistema.

Campos-chave: email, email_hash, name, password_hash, role (super_admin | admin | editor | viewer), avatar, is_active, last_login_at.

operator_sessions — sessões de operador. Mesmo padrão da Modress/Sabiá: JWT + refresh token, IP, user agent.

api_partners — empresas e instituições com acesso à API (fase 2). Geridas pelo admin.

Campos-chave: name, slug, contact_email, website, api_key_hash (nunca em claro), plan (free | starter | pro | enterprise), quota_monthly, calls_this_month, is_active, approved_by (FK → operator_users), approved_at.

api_usage_logs — log imutável de cada chamada à API. Para facturação e auditoria.

Campos-chave: partner_id, endpoint, service_id (nullable — qual serviço foi pedido), response_status, response_time_ms, ip_address, called_at.


5.2 Catalogue — O Coração do Sistema

ministries — os 8+ ministérios/entidades mapeados. Geridos pelo operador.

Campos-chave: name (ex: "Ministério da Justiça"), acronym (ex: MINJUSDH), slug, description, logo_url, website, whatsapp, phone, email, is_active, sort_order.

services — cada serviço disponibilizado por um ministério. É a unidade central do catálogo.

Campos-chave: ministry_id, name (ex: "Renovação do Bilhete de Identidade"), slug, description (explicação em linguagem simples), category (identity | tax | travel | social | education | business | transport | other), target_audience (citizen | business | both), estimated_duration_days (prazo de processamento), cost_aoa (custo em Kwanzas — nullable se grátis), cost_notes (ex: "mais 200 AOA de selos"), requires_appointment (none | optional | mandatory), appointment_url (nullable — link SEPE ou similar), status (active | suspended | updated | deprecated), last_verified_at (data da última verificação do conteúdo), last_verified_by (FK → operator_users), views_count, sessions_count, is_featured, sort_order, tags (jsonb array), metadata (jsonb — campos extras livres).

service_documents — lista de documentos necessários para cada serviço. Entidade separada para permitir ordenação, notas de formato e marcação de "gotcha".

Campos-chave: service_id, name (ex: "Bilhete de Identidade"), description (detalhe completo), format_note (ex: "versão impressa obrigatória — digital não aceite"), is_gotcha (bool — detalhe obscuro que frequentemente causa rejeição), is_optional (bool — documento situacional), quantity (ex: "2 cópias"), sort_order, applies_to_notes (ex: "apenas para maiores de 60 anos").

service_steps — para serviços simples, os passos internos do processo (ex: "1. Pagar taxa 2. Preencher formulário 3. Entregar no balcão"). Diferente dos Flows que são sequências inter-serviços.

Campos-chave: service_id, step_number, title, description, warning (nullable — aviso importante neste passo), duration_note (ex: "este passo demora até 72h").

service_versions — histórico de alterações ao serviço. Cada publicação de alteração cria uma versão.

Campos-chave: service_id, version_number, snapshot (jsonb — estado completo do serviço nessa versão), change_summary (o que mudou), changed_by (FK → operator_users), changed_at.


5.3 Flows — Sequências Multi-Etapa

Quando um cidadão tem uma intenção complexa (emigrar, entrar numa ordem profissional, abrir empresa), o sistema serve um Flow — uma sequência ordenada de serviços de ministérios diferentes, com dependências explícitas.

flows — um percurso de vida que envolve múltiplos serviços em sequência.

Campos-chave: name (ex: "Emigrar para Portugal"), slug, description, trigger_keywords (jsonb — palavras que activam este flow: ["emigrar", "ir para portugal", "viver fora"]), estimated_total_days (soma dos prazos de todos os passos), total_cost_aoa (soma dos custos estimados), category (emigration | professional_registration | business | education | identity | other), is_active, views_count, sessions_count, is_featured, sort_order, created_by (FK → operator_users), published_at.

flow_steps — cada etapa de um Flow. Aponta para um serviço existente no catálogo.

Campos-chave: flow_id, service_id (FK → services — o serviço a realizar neste passo), step_number, title (pode override o nome do serviço para contexto do flow), notes (instruções específicas neste contexto), is_blocking (bool — este passo tem de estar concluído antes do próximo), depends_on_step (nullable — número do passo que precisa de estar feito primeiro), estimated_days, cost_aoa.


5.4 Locations — Onde Ir Fisicamente

locations — balcões, postos e repartições onde os serviços são prestados.

Campos-chave: name (ex: "SIAC — Ingombota"), slug, ministry_id, type (balcao | posto | reparticao | online), address, province_id (FK → provinces), municipality_id (FK → municipalities), latitude, longitude, phone, email, website, status (operational | suspended | limited | closed), status_note (ex: "Sistema em manutenção — apenas serviços de urgência"), status_updated_at, opening_hours (jsonb — estrutura por dia da semana), appointment_required (bool), appointment_url, is_active, last_verified_at.

service_locations — many-to-many: qual serviço é disponibilizado em qual local.

Campos-chave: service_id, location_id, notes (ex: "Só aceita pedidos de renovação — 1ª emissão apenas em Luanda/SIAC"), is_primary (bool — local recomendado para este serviço).


5.5 Conversations — Sessões WhatsApp

O cidadão não tem conta. Mas cada conversa WhatsApp tem um estado — qual serviço está a ser explicado, em que passo do flow está, que pergunta de clarificação foi feita.

conversations — uma sessão de conversa num número WhatsApp.

Campos-chave: whatsapp_number_hash (hash do número — nunca em claro), channel (whatsapp | telegram | sms | web), province_id (nullable — inferido ou declarado pelo cidadão), municipality_id (nullable), status (active | completed | abandoned | error), current_service_id (nullable — serviço activo na conversa), current_flow_id (nullable — flow activo), current_flow_step (nullable — passo actual no flow), intent_raw (última intenção detectada em texto), intent_classified (serviço/flow que o NLU mapeou), intent_confidence (float — score do NLU), message_count, started_at, last_message_at, completed_at, abandoned_at.

conversation_messages — cada mensagem trocada na conversa. Log imutável.

Campos-chave: conversation_id, direction (inbound | outbound), content (texto da mensagem), message_type (text | list | button | template | media), whatsapp_message_id (ID da mensagem na WhatsApp Business API), sent_at, delivered_at, read_at, error_code (nullable — se a entrega falhou).

conversation_feedback — feedback do cidadão no final da conversa (opcional).

Campos-chave: conversation_id, rating (1–5), was_helpful (bool), comment (texto livre), submitted_at.


5.6 NLU — Classificação de Intenção

nlu_intents — intenções conhecidas pelo sistema. Mapeiam texto do cidadão para um serviço ou flow.

Campos-chave: name (ex: "renovar_bi"), entity_type (service | flow), entity_id (FK para o serviço ou flow correspondente), description, is_active, match_count (quantas vezes foi activado — actualizado async).

nlu_training_phrases — frases de treino por intenção. Alimentam o modelo de classificação.

Campos-chave: intent_id, phrase (ex: "quero renovar o bilhete", "bi caducado", "como renovar identidade"), language (pt | pt-AO), is_active, added_by (FK → operator_users).

nlu_unmatched_logs — mensagens do cidadão que o NLU não conseguiu classificar. Fonte de treino.

Campos-chave: conversation_id, message (texto original), suggested_intent_id (nullable — melhor guess do modelo com score baixo), suggested_confidence, was_resolved (bool — operador resolveu manualmente), resolved_intent_id (FK → nlu_intents), resolved_by (FK → operator_users), logged_at.


5.7 Analytics — O Que o Estado Precisa de Ver

Sem tabelas próprias na maioria dos casos — servido por queries sobre conversations + conversation_messages + service/flow. Excepções:

analytics_daily_snapshots — snapshot diário calculado por cron job. Evita recalcular em real-time.

Campos-chave: date, province_id (nullable — pode ser por província), total_sessions, completed_sessions, abandoned_sessions, unique_numbers (contagem de hashes únicos), top_services (jsonb — top 10 com contagens), top_flows (jsonb), unmatched_count, avg_messages_per_session, avg_session_duration_seconds, calculated_at.

search_analytics — pesquisas anonimizadas para descoberta de gaps no catálogo.

Campos-chave: query (texto do cidadão normalizado), matched_service_id (nullable), matched_flow_id (nullable), was_matched (bool), confidence, province_id (nullable), queried_at.


5.8 Notifications — Alertas Internos

operator_notifications — alertas para a equipa do Balcão AI.

Campos-chave: recipient_id (FK → operator_users), type (content_outdated | high_unmatched | location_suspended | volume_spike | api_quota_warning), priority (low | normal | high | urgent), title, message, action_url, data (jsonb), related_type, related_id, is_read, read_at, created_at.


5.9 Webhooks — Integração WhatsApp

webhook_events — eventos recebidos da WhatsApp Business API. Log de entrada.

Campos-chave: provider (whatsapp | twilio | meta), event_type (message | status | template_status), payload (jsonb — payload completo), processed (bool), processed_at, error (nullable — erro de processamento), received_at.

webhook_outbound_logs — mensagens enviadas para o cidadão. Para rastrear entrega.

Campos-chave: conversation_id, provider, recipient_hash, template_name (nullable), payload (jsonb), status (pending | sent | delivered | failed), provider_message_id, sent_at, status_updated_at.


6. Fluxos Detalhados

6.1 Fluxo de Classificação de Intenção

1. Mensagem chega via WhatsApp Business API → webhook_events criado
2. Worker processa o evento:
   a. Encontra ou cria conversation (por hash do número)
   b. Cria conversation_message (inbound)
3. NLU classifica a mensagem:
   a. Confidence ≥ 0.7 → serviço/flow identificado
   b. 0.4 ≤ confidence < 0.7 → pede clarificação ("Quer dizer X ou Y?")
   c. confidence < 0.4 → nlu_unmatched_log + resposta de fallback
4. Sistema compõe resposta com dados do catálogo
5. Envia resposta → conversation_message (outbound) + webhook_outbound_log
6. Actualiza conversation.current_service_id / current_flow_id

6.2 Fluxo de Actualização de Serviço (Operador)

1. Operador edita serviço no painel (documentos, custos, notas)
2. Clica "Publicar"
3. Sistema:
   a. Cria service_version com snapshot anterior
   b. Actualiza service.last_verified_at + last_verified_by
   c. Se mudança crítica (gotcha novo) → cria operator_notification para todos os admins
4. Serviço actualizado fica imediatamente activo para novas conversas

6.3 Fluxo de Analytics Diário (Cron)

1. Cron job às 02:00 WAT
2. Agrega conversations do dia anterior:
   a. Por status (completed / abandoned)
   b. Por serviço mais pedido
   c. Por província
   d. Contagem de unmatched
3. Escreve analytics_daily_snapshot
4. Actualiza service.sessions_count e flow.sessions_count
5. Se unmatched_count > threshold → cria operator_notification

6.4 Fluxo de Onboarding de Parceiro API (Fase 2)

1. Empresa pede acesso → admin aprova no painel
2. Sistema gera API key → hash armazenado em api_partners.api_key_hash
3. Key enviada uma única vez por email — nunca armazenada em claro
4. Parceiro chama API → cada chamada valida key (bcrypt compare)
5. api_usage_log criado por chamada
6. Se calls_this_month ≥ quota_monthly → resposta 429 + operator_notification
7. Reset de quota no dia 1 do mês (cron)

7. O Que NÃO Existe no Balcão AI (decisões de não-fazer)

  • Agendamento real de serviços (V1) — V1 mostra o link do SEPE. Integração real com SEPE API é V2 quando a API estiver documentada e estável.
  • Conta de cidadão — o cidadão não se regista. Privacidade por design. O número é sempre hashado.
  • Chat ao vivo com funcionário — o sistema guia, não substitui o atendimento presencial.
  • Scraping automático de portais do Estado — conteúdo é curado manualmente pelo operador. A qualidade vem da curadoria humana, não da automação.
  • Notificações push ao cidadão — o cidadão inicia sempre a conversa. Zero spam.
  • Armazenamento de dados pessoais — nome, NBI, endereço — nada disso entra no sistema. Apenas o hash do número e a intenção.
  • Multi-idioma (V1) — apenas Português. Kikongo, Kimbundu e Umbundu são V2.
  • App nativa — WhatsApp é o canal. App própria é custo sem benefício no curto prazo.
  • ML de recomendação — V1 usa matching de keywords e NLU simples. ML é quando há dados suficientes.

8. Decisões de Arquitectura

Decisão Escolha Motivo
Canal principal WhatsApp 30,6M utilizadores em Angola — canal dominante sem alternativa
Identidade do cidadão Hash do número de telefone Privacidade por design — zero dados pessoais armazenados
NLU V1 Groq / llama-3.3-70b + intent matching Rápido, barato, suficiente para as ~50 intenções do catálogo inicial
Documentos Entidade separada service_documents Permite gotcha flag, format_note, is_optional — impossível de modelar como simples array
Flows Entidade separada de Services Lógica de sequência e dependências é complexa — merece modelo próprio
Histórico de versões service_versions com jsonb snapshot Auditoria completa — saber o que o cidadão viu em cada data
Analytics Snapshot diário em tabela dedicada Queries de dashboard são O(1) — não recalculam sobre milhões de mensagens
Auth operador Tabela separada operator_users Isolamento total — mesmo padrão da Modress/Sabiá
API partners api_key_hash nunca em claro Segurança básica — key é gerada uma vez e não pode ser recuperada
Locations Tabela dedicada com status Postos fecham, sistemas caem — o estado do posto precisa de ser modelado explicitamente
NLU unmatched Log dedicado nlu_unmatched_logs Fonte de treino para melhorar o modelo — gaps do catálogo vêm daqui
Conversas Anonimizadas por defeito RGPD-compatible desde o início
Categorias de serviço Enum no services.category Taxonomia controlada — cidadão não cria categorias

9. Métricas que o Operador Acompanha

Métrica O que mede
Sessões totais (diário/semanal) Adopção — quantas pessoas usaram
Taxa de sessões completas Eficácia — o cidadão ficou com a resposta que precisava
Top serviços pedidos O que a população mais precisa — input para prioridade do catálogo
Unmatched rate Gaps do catálogo — o que o cidadão pede que não existe
Drop-off por serviço Onde o cidadão abandona — conteúdo confuso ou incompleto
Volume por província Cobertura geográfica — onde há mais necessidade não servida
Localidades com maior abandono Onde os balcões físicos estão a falhar
Tempo médio de sessão Complexidade — conversas muito longas indicam ambiguidade
Feedback rating médio Satisfação directa do cidadão

10. Reutilização do Core (mesmo padrão da Modress/Sabiá)

O que reutilizamos Ajustes necessários
Padrão operator_users Adicionar role (super_admin / admin / editor / viewer)
Padrão sessions Zero ajustes — JWT + refresh, IP, user agent
Padrão activity_log Zero ajustes
Padrão notifications Renomeado operator_notifications — só para staff interno
provinces + municipalities Zero ajustes — reutilizados para locations e conversations
Padrão auth (JWT + refresh) Zero ajustes
Slugs únicos em todas as entidades Zero ajustes — mesmo padrão
Soft delete com deleted_at Zero ajustes

Schema Drizzle (TypeScript / PostgreSQL)

import {
  pgTable, uuid, varchar, text, boolean,
  integer, jsonb, timestamp, real, index, uniqueIndex, inet,
} from 'drizzle-orm/pg-core'
import { sql, relations } from 'drizzle-orm'

// ─────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────
const id        = () => uuid('id').primaryKey().default(sql`uuid_generate_v7()`)
const now       = () => timestamp('created_at').defaultNow().notNull()
const updatedAt = () => timestamp('updated_at').defaultNow().notNull()
const deletedAt = () => timestamp('deleted_at')


// ═════════════════════════════════════════════
// REUTILIZADO DO CORE — zero alterações
// ═════════════════════════════════════════════

export const provinces = pgTable('provinces', {
  id:        uuid('id').primaryKey(),
  name:      varchar('name', { length: 100 }).notNull(),
  code:      varchar('code', { length: 10 }).notNull(),
  isActive:  boolean('is_active').notNull().default(true),
  sortOrder: integer('sort_order').notNull().default(0),
  createdAt: now(),
}, t => ({
  codeIdx: uniqueIndex('provinces_code_idx').on(t.code),
}))

export const municipalities = pgTable('municipalities', {
  id:         uuid('id').primaryKey(),
  provinceId: uuid('province_id').notNull().references(() => provinces.id, { onDelete: 'cascade' }),
  name:       varchar('name', { length: 100 }).notNull(),
  code:       varchar('code', { length: 20 }),
  isActive:   boolean('is_active').notNull().default(true),
  sortOrder:  integer('sort_order').notNull().default(0),
  createdAt:  now(),
}, t => ({
  provinceIdx: index('municipalities_province_idx').on(t.provinceId),
}))

export const activityLog = pgTable('activity_log', {
  id:           id(),
  actorId:      uuid('actor_id').references(() => operatorUsers.id, { onDelete: 'set null' }),
  action:       varchar('action', { length: 100 }).notNull(),
  entityType:   varchar('entity_type', { length: 50 }).notNull(),
  entityId:     uuid('entity_id').notNull(),
  metadata:     jsonb('metadata').default({}),
  ipAddress:    inet('ip_address'),
  userAgent:    text('user_agent'),
  createdAt:    now(),
}, t => ({
  entityIdx:  index('activity_entity_idx').on(t.entityType, t.entityId),
  actorIdx:   index('activity_actor_idx').on(t.actorId),
  createdIdx: index('activity_created_idx').on(t.createdAt),
}))


// ═════════════════════════════════════════════
// IDENTITY — Operadores e Parceiros
// ═════════════════════════════════════════════

export const operatorUsers = pgTable('operator_users', {
  id:           id(),
  email:        text('email').notNull(),
  emailHash:    text('email_hash').notNull(),
  name:         text('name').notNull(),
  passwordHash: text('password_hash').notNull(),
  role:         varchar('role', { length: 30 }).notNull().default('editor'),
  // super_admin | admin | editor | viewer
  avatar:       text('avatar'),
  isActive:     boolean('is_active').notNull().default(true),
  lastLoginAt:  timestamp('last_login_at'),
  createdAt:    now(),
  updatedAt:    updatedAt(),
  deletedAt:    deletedAt(),
}, t => ({
  emailHashIdx: uniqueIndex('operator_email_hash_idx').on(t.emailHash),
  roleIdx:      index('operator_role_idx').on(t.role),
}))

export const operatorSessions = pgTable('operator_sessions', {
  id:           id(),
  operatorId:   uuid('operator_id').notNull().references(() => operatorUsers.id, { onDelete: 'cascade' }),
  token:        text('token').notNull().unique(),
  refreshToken: text('refresh_token').unique(),
  userAgent:    text('user_agent'),
  ipAddress:    text('ip_address'),
  deviceType:   varchar('device_type', { length: 30 }),
  isActive:     boolean('is_active').notNull().default(true),
  expiresAt:    timestamp('expires_at').notNull(),
  createdAt:    now(),
  updatedAt:    updatedAt(),
  revokedAt:    timestamp('revoked_at'),
}, t => ({
  operatorIdx:     index('op_sessions_operator_idx').on(t.operatorId),
  tokenIdx:        index('op_sessions_token_idx').on(t.token),
  refreshTokenIdx: index('op_sessions_refresh_token_idx').on(t.refreshToken),
  expiresAtIdx:    index('op_sessions_expires_at_idx').on(t.expiresAt),
}))

export const apiPartners = pgTable('api_partners', {
  id:             id(),
  name:           varchar('name', { length: 200 }).notNull(),
  slug:           varchar('slug', { length: 200 }).notNull(),
  contactEmail:   text('contact_email').notNull(),
  website:        text('website'),
  description:    text('description'),
  apiKeyHash:     text('api_key_hash').notNull(),
  // nunca em claro — gerada uma vez e entregue por email
  plan:           varchar('plan', { length: 30 }).notNull().default('starter'),
  // free | starter | pro | enterprise
  quotaMonthly:   integer('quota_monthly').notNull().default(1000),
  callsThisMonth: integer('calls_this_month').notNull().default(0),
  quotaResetAt:   timestamp('quota_reset_at').notNull(),
  isActive:       boolean('is_active').notNull().default(false),
  // inactivo até aprovação manual
  approvedBy:     uuid('approved_by').references(() => operatorUsers.id, { onDelete: 'set null' }),
  approvedAt:     timestamp('approved_at'),
  createdAt:      now(),
  updatedAt:      updatedAt(),
  deletedAt:      deletedAt(),
}, t => ({
  slugIdx:   uniqueIndex('api_partners_slug_idx').on(t.slug),
  activeIdx: index('api_partners_active_idx').on(t.isActive),
}))

export const apiUsageLogs = pgTable('api_usage_logs', {
  id:             id(),
  partnerId:      uuid('partner_id').notNull().references(() => apiPartners.id, { onDelete: 'cascade' }),
  endpoint:       varchar('endpoint', { length: 200 }).notNull(),
  serviceId:      uuid('service_id').references(() => services.id, { onDelete: 'set null' }),
  flowId:         uuid('flow_id').references(() => flows.id, { onDelete: 'set null' }),
  responseStatus: integer('response_status').notNull(),
  responseTimeMs: integer('response_time_ms'),
  ipAddress:      text('ip_address'),
  calledAt:       timestamp('called_at').defaultNow().notNull(),
}, t => ({
  partnerIdx: index('api_usage_partner_idx').on(t.partnerId),
  calledIdx:  index('api_usage_called_idx').on(t.calledAt),
  statusIdx:  index('api_usage_status_idx').on(t.responseStatus),
}))

export const operatorNotifications = pgTable('operator_notifications', {
  id:          id(),
  recipientId: uuid('recipient_id').notNull().references(() => operatorUsers.id, { onDelete: 'cascade' }),
  type:        varchar('type', { length: 100 }).notNull(),
  // content_outdated | high_unmatched | location_suspended | volume_spike | api_quota_warning
  priority:    varchar('priority', { length: 20 }).notNull().default('normal'),
  // low | normal | high | urgent
  title:       varchar('title', { length: 255 }).notNull(),
  message:     text('message'),
  actionUrl:   text('action_url'),
  data:        jsonb('data'),
  relatedType: varchar('related_type', { length: 50 }),
  relatedId:   uuid('related_id'),
  isRead:      boolean('is_read').notNull().default(false),
  readAt:      timestamp('read_at'),
  createdAt:   now(),
  deletedAt:   deletedAt(),
}, t => ({
  recipientIdx: index('op_notifications_recipient_idx').on(t.recipientId),
  unreadIdx:    index('op_notifications_unread_idx').on(t.recipientId, t.isRead),
}))


// ═════════════════════════════════════════════
// CATALOGUE — Ministérios, Serviços, Documentos
// ═════════════════════════════════════════════

export const ministries = pgTable('ministries', {
  id:          id(),
  name:        varchar('name', { length: 200 }).notNull(),
  acronym:     varchar('acronym', { length: 30 }).notNull(),
  // MINJUSDH | AGT | SME | MIREX | MAPTSS | INAAREES | GUE | DNTR
  slug:        varchar('slug', { length: 200 }).notNull(),
  description: text('description'),
  logoUrl:     text('logo_url'),
  website:     text('website'),
  whatsapp:    varchar('whatsapp', { length: 30 }),
  phone:       varchar('phone', { length: 30 }),
  email:       text('email'),
  isActive:    boolean('is_active').notNull().default(true),
  sortOrder:   integer('sort_order').notNull().default(0),
  createdAt:   now(),
  updatedAt:   updatedAt(),
}, t => ({
  slugIdx:    uniqueIndex('ministries_slug_idx').on(t.slug),
  acronymIdx: uniqueIndex('ministries_acronym_idx').on(t.acronym),
  activeIdx:  index('ministries_active_idx').on(t.isActive),
}))

export const services = pgTable('services', {
  id:                  id(),
  ministryId:          uuid('ministry_id').notNull().references(() => ministries.id, { onDelete: 'restrict' }),
  name:                varchar('name', { length: 300 }).notNull(),
  slug:                varchar('slug', { length: 300 }).notNull(),
  description:         text('description'),
  // explicação em linguagem simples — o que este serviço faz e para quem
  category:            varchar('category', { length: 50 }).notNull(),
  // identity | tax | travel | social | education | business | transport | other
  targetAudience:      varchar('target_audience', { length: 20 }).notNull().default('citizen'),
  // citizen | business | both
  estimatedDurationDays: integer('estimated_duration_days'),
  // prazo de processamento após entrega de documentos
  costAoa:             integer('cost_aoa'),
  // custo em Kwanzas — null se gratuito
  costNotes:           text('cost_notes'),
  // ex: "mais 200 AOA de selos — comprar na bilheteira do SIAC"
  requiresAppointment: varchar('requires_appointment', { length: 20 }).notNull().default('none'),
  // none | optional | mandatory
  appointmentUrl:      text('appointment_url'),
  status:              varchar('status', { length: 30 }).notNull().default('active'),
  // active | suspended | updated | deprecated
  statusNote:          text('status_note'),
  // ex: "Serviço suspenso por actualização do sistema SEPE"
  lastVerifiedAt:      timestamp('last_verified_at'),
  lastVerifiedBy:      uuid('last_verified_by').references(() => operatorUsers.id, { onDelete: 'set null' }),
  viewsCount:          integer('views_count').notNull().default(0),
  sessionsCount:       integer('sessions_count').notNull().default(0),
  isFeatured:          boolean('is_featured').notNull().default(false),
  sortOrder:           integer('sort_order').notNull().default(0),
  tags:                jsonb('tags').default([]).$type<string[]>(),
  metadata:            jsonb('metadata').default({}).$type<Record<string, unknown>>(),
  createdBy:           uuid('created_by').references(() => operatorUsers.id, { onDelete: 'set null' }),
  publishedAt:         timestamp('published_at'),
  createdAt:           now(),
  updatedAt:           updatedAt(),
  deletedAt:           deletedAt(),
}, t => ({
  slugIdx:      uniqueIndex('services_slug_idx').on(t.slug),
  ministryIdx:  index('services_ministry_idx').on(t.ministryId),
  categoryIdx:  index('services_category_idx').on(t.category),
  statusIdx:    index('services_status_idx').on(t.status),
  featuredIdx:  index('services_featured_idx').on(t.isFeatured, t.status),
  publishedIdx: index('services_published_idx').on(t.publishedAt),
}))

export const serviceDocuments = pgTable('service_documents', {
  id:            id(),
  serviceId:     uuid('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }),
  name:          varchar('name', { length: 200 }).notNull(),
  // ex: "Bilhete de Identidade"
  description:   text('description'),
  // ex: "BI original + 2 fotocópias autenticadas"
  formatNote:    text('format_note'),
  // ex: "versão impressa obrigatória — digital não aceite"
  isGotcha:      boolean('is_gotcha').notNull().default(false),
  // true = detalhe obscuro que frequentemente causa rejeição no balcão
  isOptional:    boolean('is_optional').notNull().default(false),
  // true = documento situacional (ex: "só para maiores de 60 anos")
  quantity:      varchar('quantity', { length: 50 }),
  // ex: "2 cópias", "original + fotocópia"
  appliesToNotes: text('applies_to_notes'),
  // ex: "apenas para 1ª emissão — não necessário na renovação"
  sortOrder:     integer('sort_order').notNull().default(0),
  createdAt:     now(),
  updatedAt:     updatedAt(),
}, t => ({
  serviceIdx:  index('service_documents_service_idx').on(t.serviceId),
  gotchaIdx:   index('service_documents_gotcha_idx').on(t.serviceId, t.isGotcha),
  sortIdx:     index('service_documents_sort_idx').on(t.serviceId, t.sortOrder),
}))

export const serviceSteps = pgTable('service_steps', {
  id:           id(),
  serviceId:    uuid('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }),
  stepNumber:   integer('step_number').notNull(),
  title:        varchar('title', { length: 200 }).notNull(),
  description:  text('description').notNull(),
  warning:      text('warning'),
  // nullable — aviso crítico neste passo específico
  durationNote: text('duration_note'),
  // ex: "este passo demora até 72h úteis"
  createdAt:    now(),
  updatedAt:    updatedAt(),
}, t => ({
  serviceIdx: index('service_steps_service_idx').on(t.serviceId),
  orderIdx:   uniqueIndex('service_steps_order_idx').on(t.serviceId, t.stepNumber),
}))

export const serviceVersions = pgTable('service_versions', {
  id:            id(),
  serviceId:     uuid('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }),
  versionNumber: integer('version_number').notNull(),
  snapshot:      jsonb('snapshot').notNull().$type<Record<string, unknown>>(),
  // estado completo do serviço (documentos, custos, horários) nesta versão
  changeSummary: text('change_summary'),
  // o que mudou em relação à versão anterior
  changedBy:     uuid('changed_by').references(() => operatorUsers.id, { onDelete: 'set null' }),
  changedAt:     timestamp('changed_at').defaultNow().notNull(),
}, t => ({
  serviceIdx: index('service_versions_service_idx').on(t.serviceId),
  orderIdx:   uniqueIndex('service_versions_order_idx').on(t.serviceId, t.versionNumber),
}))


// ═════════════════════════════════════════════
// FLOWS — Sequências Multi-Etapa
// ═════════════════════════════════════════════

export const flows = pgTable('flows', {
  id:                   id(),
  name:                 varchar('name', { length: 300 }).notNull(),
  // ex: "Emigrar para Portugal", "Entrar na Ordem dos Enfermeiros"
  slug:                 varchar('slug', { length: 300 }).notNull(),
  description:          text('description'),
  triggerKeywords:      jsonb('trigger_keywords').default([]).$type<string[]>(),
  // ex: ["emigrar", "ir para portugal", "viver fora"] — activam este flow no NLU
  estimatedTotalDays:   integer('estimated_total_days'),
  // soma dos prazos de todos os passos
  totalCostAoa:         integer('total_cost_aoa'),
  // soma dos custos estimados
  category:             varchar('category', { length: 50 }).notNull(),
  // emigration | professional_registration | business | education | identity | other
  isActive:             boolean('is_active').notNull().default(true),
  viewsCount:           integer('views_count').notNull().default(0),
  sessionsCount:        integer('sessions_count').notNull().default(0),
  isFeatured:           boolean('is_featured').notNull().default(false),
  sortOrder:            integer('sort_order').notNull().default(0),
  createdBy:            uuid('created_by').references(() => operatorUsers.id, { onDelete: 'set null' }),
  publishedAt:          timestamp('published_at'),
  createdAt:            now(),
  updatedAt:            updatedAt(),
  deletedAt:            deletedAt(),
}, t => ({
  slugIdx:      uniqueIndex('flows_slug_idx').on(t.slug),
  categoryIdx:  index('flows_category_idx').on(t.category),
  activeIdx:    index('flows_active_idx').on(t.isActive),
  featuredIdx:  index('flows_featured_idx').on(t.isFeatured, t.isActive),
}))

export const flowSteps = pgTable('flow_steps', {
  id:              id(),
  flowId:          uuid('flow_id').notNull().references(() => flows.id, { onDelete: 'cascade' }),
  serviceId:       uuid('service_id').notNull().references(() => services.id, { onDelete: 'restrict' }),
  // o serviço a realizar neste passo — não pode ser deletado se estiver em uso
  stepNumber:      integer('step_number').notNull(),
  title:           varchar('title', { length: 300 }),
  // pode override o nome do serviço para contextualizar no flow
  notes:           text('notes'),
  // instruções específicas neste contexto (ex: "o certificado do passo 1 tem de estar autenticado antes de continuar")
  isBlocking:      boolean('is_blocking').notNull().default(true),
  // true = tem de estar concluído antes do próximo
  dependsOnStep:   integer('depends_on_step'),
  // nullable — número do passo que precisa de estar feito primeiro
  estimatedDays:   integer('estimated_days'),
  costAoa:         integer('cost_aoa'),
  createdAt:       now(),
  updatedAt:       updatedAt(),
}, t => ({
  flowIdx:     index('flow_steps_flow_idx').on(t.flowId),
  serviceIdx:  index('flow_steps_service_idx').on(t.serviceId),
  orderIdx:    uniqueIndex('flow_steps_order_idx').on(t.flowId, t.stepNumber),
}))


// ═════════════════════════════════════════════
// LOCATIONS — Balcões e Repartições
// ═════════════════════════════════════════════

export const locations = pgTable('locations', {
  id:                 id(),
  ministryId:         uuid('ministry_id').notNull().references(() => ministries.id, { onDelete: 'restrict' }),
  name:               varchar('name', { length: 200 }).notNull(),
  // ex: "SIAC — Ingombota", "Repartição Fiscal de Viana"
  slug:               varchar('slug', { length: 200 }).notNull(),
  type:               varchar('type', { length: 30 }).notNull().default('balcao'),
  // balcao | posto | reparticao | online
  address:            text('address'),
  provinceId:         uuid('province_id').references(() => provinces.id, { onDelete: 'set null' }),
  municipalityId:     uuid('municipality_id').references(() => municipalities.id, { onDelete: 'set null' }),
  latitude:           real('latitude'),
  longitude:          real('longitude'),
  phone:              varchar('phone', { length: 30 }),
  email:              text('email'),
  website:            text('website'),
  status:             varchar('status', { length: 30 }).notNull().default('operational'),
  // operational | suspended | limited | closed
  statusNote:         text('status_note'),
  // ex: "Sistema em manutenção — apenas serviços de urgência"
  statusUpdatedAt:    timestamp('status_updated_at'),
  openingHours:       jsonb('opening_hours').default({}).$type<Record<string, unknown>>(),
  // estrutura: { mon: { open: "08:00", close: "16:00" }, tue: ..., sat: null }
  appointmentRequired: boolean('appointment_required').notNull().default(false),
  appointmentUrl:     text('appointment_url'),
  isActive:           boolean('is_active').notNull().default(true),
  lastVerifiedAt:     timestamp('last_verified_at'),
  createdAt:          now(),
  updatedAt:          updatedAt(),
  deletedAt:          deletedAt(),
}, t => ({
  slugIdx:       uniqueIndex('locations_slug_idx').on(t.slug),
  ministryIdx:   index('locations_ministry_idx').on(t.ministryId),
  provinceIdx:   index('locations_province_idx').on(t.provinceId),
  statusIdx:     index('locations_status_idx').on(t.status),
  coordsIdx:     index('locations_coords_idx').on(t.latitude, t.longitude),
}))

export const serviceLocations = pgTable('service_locations', {
  id:         id(),
  serviceId:  uuid('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }),
  locationId: uuid('location_id').notNull().references(() => locations.id, { onDelete: 'cascade' }),
  notes:      text('notes'),
  // ex: "Só aceita renovações — 1ª emissão apenas no SIAC central"
  isPrimary:  boolean('is_primary').notNull().default(false),
  // local recomendado para este serviço
}, t => ({
  uniqueIdx:    uniqueIndex('service_locations_unique_idx').on(t.serviceId, t.locationId),
  serviceIdx:   index('service_locations_service_idx').on(t.serviceId),
  locationIdx:  index('service_locations_location_idx').on(t.locationId),
  primaryIdx:   index('service_locations_primary_idx').on(t.serviceId, t.isPrimary),
}))


// ═════════════════════════════════════════════
// CONVERSATIONS — Sessões WhatsApp
// ═════════════════════════════════════════════

export const conversations = pgTable('conversations', {
  id:                   id(),
  whatsappNumberHash:   text('whatsapp_number_hash').notNull(),
  // hash SHA-256 do número — nunca em claro
  channel:              varchar('channel', { length: 20 }).notNull().default('whatsapp'),
  // whatsapp | telegram | sms | web
  provinceId:           uuid('province_id').references(() => provinces.id, { onDelete: 'set null' }),
  // inferido por prefixo do número ou declarado pelo cidadão
  municipalityId:       uuid('municipality_id').references(() => municipalities.id, { onDelete: 'set null' }),
  status:               varchar('status', { length: 20 }).notNull().default('active'),
  // active | completed | abandoned | error
  currentServiceId:     uuid('current_service_id').references(() => services.id, { onDelete: 'set null' }),
  currentFlowId:        uuid('current_flow_id').references(() => flows.id, { onDelete: 'set null' }),
  currentFlowStep:      integer('current_flow_step'),
  // passo actual no flow activo
  intentRaw:            text('intent_raw'),
  // última mensagem do cidadão que gerou uma classificação
  intentClassified:     varchar('intent_classified', { length: 100 }),
  // ex: "renovar_bi", "flow:emigrar_portugal"
  intentConfidence:     real('intent_confidence'),
  // score do NLU: 0.0–1.0
  messageCount:         integer('message_count').notNull().default(0),
  startedAt:            timestamp('started_at').defaultNow().notNull(),
  lastMessageAt:        timestamp('last_message_at').defaultNow().notNull(),
  completedAt:          timestamp('completed_at'),
  abandonedAt:          timestamp('abandoned_at'),
}, t => ({
  numberHashIdx:    index('conversations_number_hash_idx').on(t.whatsappNumberHash),
  statusIdx:        index('conversations_status_idx').on(t.status),
  serviceIdx:       index('conversations_service_idx').on(t.currentServiceId),
  flowIdx:          index('conversations_flow_idx').on(t.currentFlowId),
  provinceIdx:      index('conversations_province_idx').on(t.provinceId),
  lastMessageIdx:   index('conversations_last_message_idx').on(t.lastMessageAt),
}))

export const conversationMessages = pgTable('conversation_messages', {
  id:                  id(),
  conversationId:      uuid('conversation_id').notNull().references(() => conversations.id, { onDelete: 'cascade' }),
  direction:           varchar('direction', { length: 10 }).notNull(),
  // inbound | outbound
  content:             text('content').notNull(),
  messageType:         varchar('message_type', { length: 30 }).notNull().default('text'),
  // text | list | button | template | media
  whatsappMessageId:   varchar('whatsapp_message_id', { length: 100 }),
  // ID da mensagem na WhatsApp Business API
  sentAt:              timestamp('sent_at').defaultNow().notNull(),
  deliveredAt:         timestamp('delivered_at'),
  readAt:              timestamp('read_at'),
  errorCode:           varchar('error_code', { length: 50 }),
  // nullable — se a entrega falhou
}, t => ({
  conversationIdx: index('conv_messages_conversation_idx').on(t.conversationId),
  directionIdx:    index('conv_messages_direction_idx').on(t.conversationId, t.direction),
  sentAtIdx:       index('conv_messages_sent_at_idx').on(t.sentAt),
  waMessageIdx:    index('conv_messages_wa_id_idx').on(t.whatsappMessageId),
}))

export const conversationFeedback = pgTable('conversation_feedback', {
  id:             id(),
  conversationId: uuid('conversation_id').notNull().references(() => conversations.id, { onDelete: 'cascade' }),
  rating:         integer('rating').notNull(),
  // 1–5
  wasHelpful:     boolean('was_helpful').notNull(),
  comment:        text('comment'),
  submittedAt:    timestamp('submitted_at').defaultNow().notNull(),
}, t => ({
  conversationIdx: uniqueIndex('conv_feedback_conversation_idx').on(t.conversationId),
  // um feedback por conversa
  ratingIdx:       index('conv_feedback_rating_idx').on(t.rating),
}))


// ═════════════════════════════════════════════
// NLU — Classificação de Intenção
// ═════════════════════════════════════════════

export const nluIntents = pgTable('nlu_intents', {
  id:          id(),
  name:        varchar('name', { length: 100 }).notNull(),
  // ex: "renovar_bi", "tirar_passaporte", "flow:emigrar_portugal"
  entityType:  varchar('entity_type', { length: 20 }).notNull(),
  // service | flow
  entityId:    uuid('entity_id').notNull(),
  // FK para services.id ou flows.id — sem FK nativa (polimórfico)
  description: text('description'),
  isActive:    boolean('is_active').notNull().default(true),
  matchCount:  integer('match_count').notNull().default(0),
  // actualizado async — total de vezes que esta intenção foi activada
  createdAt:   now(),
  updatedAt:   updatedAt(),
}, t => ({
  nameIdx:     uniqueIndex('nlu_intents_name_idx').on(t.name),
  entityIdx:   index('nlu_intents_entity_idx').on(t.entityType, t.entityId),
  activeIdx:   index('nlu_intents_active_idx').on(t.isActive),
}))

export const nluTrainingPhrases = pgTable('nlu_training_phrases', {
  id:        id(),
  intentId:  uuid('intent_id').notNull().references(() => nluIntents.id, { onDelete: 'cascade' }),
  phrase:    text('phrase').notNull(),
  // ex: "quero renovar o bilhete", "bi caducado", "como renovar identidade"
  language:  varchar('language', { length: 10 }).notNull().default('pt'),
  // pt | pt-AO
  isActive:  boolean('is_active').notNull().default(true),
  addedBy:   uuid('added_by').references(() => operatorUsers.id, { onDelete: 'set null' }),
  createdAt: now(),
}, t => ({
  intentIdx:  index('nlu_phrases_intent_idx').on(t.intentId),
  phraseIdx:  index('nlu_phrases_phrase_idx').on(t.phrase),
  activeIdx:  index('nlu_phrases_active_idx').on(t.intentId, t.isActive),
}))

export const nluUnmatchedLogs = pgTable('nlu_unmatched_logs', {
  id:                  id(),
  conversationId:      uuid('conversation_id').references(() => conversations.id, { onDelete: 'set null' }),
  message:             text('message').notNull(),
  // texto original do cidadão que não foi classificado
  suggestedIntentId:   uuid('suggested_intent_id').references(() => nluIntents.id, { onDelete: 'set null' }),
  // melhor guess do modelo com score abaixo do threshold
  suggestedConfidence: real('suggested_confidence'),
  wasResolved:         boolean('was_resolved').notNull().default(false),
  resolvedIntentId:    uuid('resolved_intent_id').references(() => nluIntents.id, { onDelete: 'set null' }),
  // operador associou manualmente a uma intenção existente ou criou uma nova
  resolvedBy:          uuid('resolved_by').references(() => operatorUsers.id, { onDelete: 'set null' }),
  resolvedAt:          timestamp('resolved_at'),
  loggedAt:            timestamp('logged_at').defaultNow().notNull(),
}, t => ({
  conversationIdx:  index('nlu_unmatched_conversation_idx').on(t.conversationId),
  resolvedIdx:      index('nlu_unmatched_resolved_idx').on(t.wasResolved),
  loggedAtIdx:      index('nlu_unmatched_logged_at_idx').on(t.loggedAt),
}))


// ═════════════════════════════════════════════
// ANALYTICS
// ═════════════════════════════════════════════

export const analyticsDailySnapshots = pgTable('analytics_daily_snapshots', {
  id:                       id(),
  date:                     timestamp('date').notNull(),
  // dia a que este snapshot se refere (sem hora)
  provinceId:               uuid('province_id').references(() => provinces.id, { onDelete: 'set null' }),
  // null = agregado nacional; não-null = breakdown por província
  totalSessions:            integer('total_sessions').notNull().default(0),
  completedSessions:        integer('completed_sessions').notNull().default(0),
  abandonedSessions:        integer('abandoned_sessions').notNull().default(0),
  uniqueNumbers:            integer('unique_numbers').notNull().default(0),
  // contagem de hashes únicos — proxy de utilizadores únicos
  unmatchedCount:           integer('unmatched_count').notNull().default(0),
  avgMessagesPerSession:    real('avg_messages_per_session'),
  avgSessionDurationSeconds: integer('avg_session_duration_seconds'),
  topServices:              jsonb('top_services').default([]).$type<Array<{ serviceId: string; count: number }>>(),
  topFlows:                 jsonb('top_flows').default([]).$type<Array<{ flowId: string; count: number }>>(),
  calculatedAt:             timestamp('calculated_at').defaultNow().notNull(),
}, t => ({
  dateProvinceIdx: uniqueIndex('analytics_date_province_idx').on(t.date, t.provinceId),
  dateIdx:         index('analytics_date_idx').on(t.date),
}))

export const searchAnalytics = pgTable('search_analytics', {
  id:               id(),
  query:            text('query').notNull(),
  // texto do cidadão normalizado (lowercase, trim)
  matchedServiceId: uuid('matched_service_id').references(() => services.id, { onDelete: 'set null' }),
  matchedFlowId:    uuid('matched_flow_id').references(() => flows.id, { onDelete: 'set null' }),
  wasMatched:       boolean('was_matched').notNull(),
  confidence:       real('confidence'),
  provinceId:       uuid('province_id').references(() => provinces.id, { onDelete: 'set null' }),
  queriedAt:        timestamp('queried_at').defaultNow().notNull(),
}, t => ({
  queryIdx:      index('search_analytics_query_idx').on(t.query),
  matchedIdx:    index('search_analytics_matched_idx').on(t.wasMatched),
  queriedAtIdx:  index('search_analytics_queried_at_idx').on(t.queriedAt),
}))


// ═════════════════════════════════════════════
// WEBHOOKS — Integração WhatsApp Business API
// ═════════════════════════════════════════════

export const webhookEvents = pgTable('webhook_events', {
  id:           id(),
  provider:     varchar('provider', { length: 30 }).notNull().default('meta'),
  // meta | twilio | vonage
  eventType:    varchar('event_type', { length: 50 }).notNull(),
  // message | status | template_status
  payload:      jsonb('payload').notNull().$type<Record<string, unknown>>(),
  // payload completo recebido do provider — imutável
  processed:    boolean('processed').notNull().default(false),
  processedAt:  timestamp('processed_at'),
  error:        text('error'),
  // nullable — erro de processamento (parsing, classificação, entrega)
  receivedAt:   timestamp('received_at').defaultNow().notNull(),
}, t => ({
  processedIdx: index('webhook_events_processed_idx').on(t.processed),
  receivedIdx:  index('webhook_events_received_idx').on(t.receivedAt),
  providerIdx:  index('webhook_events_provider_idx').on(t.provider),
}))

export const webhookOutboundLogs = pgTable('webhook_outbound_logs', {
  id:                id(),
  conversationId:    uuid('conversation_id').references(() => conversations.id, { onDelete: 'set null' }),
  provider:          varchar('provider', { length: 30 }).notNull().default('meta'),
  recipientHash:     text('recipient_hash').notNull(),
  // hash do número de destino
  templateName:      varchar('template_name', { length: 100 }),
  // nullable — só se for uma template message do WhatsApp
  payload:           jsonb('payload').notNull().$type<Record<string, unknown>>(),
  status:            varchar('status', { length: 20 }).notNull().default('pending'),
  // pending | sent | delivered | failed
  providerMessageId: varchar('provider_message_id', { length: 100 }),
  // ID atribuído pelo provider após envio
  sentAt:            timestamp('sent_at').defaultNow().notNull(),
  statusUpdatedAt:   timestamp('status_updated_at'),
}, t => ({
  conversationIdx:  index('outbound_conversation_idx').on(t.conversationId),
  statusIdx:        index('outbound_status_idx').on(t.status),
  sentAtIdx:        index('outbound_sent_at_idx').on(t.sentAt),
  providerMsgIdx:   index('outbound_provider_msg_idx').on(t.providerMessageId),
}))


// ═════════════════════════════════════════════
// RELATIONS
// ═════════════════════════════════════════════

export const operatorUsersRelations = relations(operatorUsers, ({ many }) => ({
  sessions:          many(operatorSessions),
  notifications:     many(operatorNotifications),
  servicesCreated:   many(services),
  servicesVerified:  many(services),
  flowsCreated:      many(flows),
  partnersApproved:  many(apiPartners),
  nluPhrasesAdded:   many(nluTrainingPhrases),
  unmatchedResolved: many(nluUnmatchedLogs),
  serviceVersions:   many(serviceVersions),
  activityLog:       many(activityLog),
}))

export const ministriesRelations = relations(ministries, ({ many }) => ({
  services:  many(services),
  locations: many(locations),
}))

export const servicesRelations = relations(services, ({ one, many }) => ({
  ministry:       one(ministries, { fields: [services.ministryId], references: [ministries.id] }),
  createdBy:      one(operatorUsers, { fields: [services.createdBy], references: [operatorUsers.id] }),
  lastVerifiedBy: one(operatorUsers, { fields: [services.lastVerifiedBy], references: [operatorUsers.id] }),
  documents:      many(serviceDocuments),
  steps:          many(serviceSteps),
  versions:       many(serviceVersions),
  locations:      many(serviceLocations),
  flowSteps:      many(flowSteps),
  apiUsageLogs:   many(apiUsageLogs),
}))

export const serviceDocumentsRelations = relations(serviceDocuments, ({ one }) => ({
  service: one(services, { fields: [serviceDocuments.serviceId], references: [services.id] }),
}))

export const serviceStepsRelations = relations(serviceSteps, ({ one }) => ({
  service: one(services, { fields: [serviceSteps.serviceId], references: [services.id] }),
}))

export const serviceVersionsRelations = relations(serviceVersions, ({ one }) => ({
  service:   one(services,       { fields: [serviceVersions.serviceId], references: [services.id] }),
  changedBy: one(operatorUsers,  { fields: [serviceVersions.changedBy], references: [operatorUsers.id] }),
}))

export const flowsRelations = relations(flows, ({ one, many }) => ({
  createdBy:   one(operatorUsers, { fields: [flows.createdBy], references: [operatorUsers.id] }),
  steps:       many(flowSteps),
  apiUsageLogs: many(apiUsageLogs),
}))

export const flowStepsRelations = relations(flowSteps, ({ one }) => ({
  flow:    one(flows,    { fields: [flowSteps.flowId],    references: [flows.id] }),
  service: one(services, { fields: [flowSteps.serviceId], references: [services.id] }),
}))

export const locationsRelations = relations(locations, ({ one, many }) => ({
  ministry:     one(ministries,    { fields: [locations.ministryId],    references: [ministries.id] }),
  province:     one(provinces,     { fields: [locations.provinceId],    references: [provinces.id] }),
  municipality: one(municipalities, { fields: [locations.municipalityId], references: [municipalities.id] }),
  services:     many(serviceLocations),
}))

export const serviceLocationsRelations = relations(serviceLocations, ({ one }) => ({
  service:  one(services,  { fields: [serviceLocations.serviceId],  references: [services.id] }),
  location: one(locations, { fields: [serviceLocations.locationId], references: [locations.id] }),
}))

export const conversationsRelations = relations(conversations, ({ one, many }) => ({
  province:      one(provinces,     { fields: [conversations.provinceId],    references: [provinces.id] }),
  municipality:  one(municipalities, { fields: [conversations.municipalityId], references: [municipalities.id] }),
  currentService: one(services,     { fields: [conversations.currentServiceId], references: [services.id] }),
  currentFlow:   one(flows,         { fields: [conversations.currentFlowId],  references: [flows.id] }),
  messages:      many(conversationMessages),
  feedback:      many(conversationFeedback),
}))

export const conversationMessagesRelations = relations(conversationMessages, ({ one }) => ({
  conversation: one(conversations, { fields: [conversationMessages.conversationId], references: [conversations.id] }),
}))

export const nluIntentsRelations = relations(nluIntents, ({ many }) => ({
  trainingPhrases: many(nluTrainingPhrases),
  unmatchedResolved: many(nluUnmatchedLogs, { relationName: 'resolvedIntent' }),
}))

export const nluTrainingPhrasesRelations = relations(nluTrainingPhrases, ({ one }) => ({
  intent:  one(nluIntents,    { fields: [nluTrainingPhrases.intentId], references: [nluIntents.id] }),
  addedBy: one(operatorUsers, { fields: [nluTrainingPhrases.addedBy],  references: [operatorUsers.id] }),
}))

export const nluUnmatchedLogsRelations = relations(nluUnmatchedLogs, ({ one }) => ({
  conversation:    one(conversations, { fields: [nluUnmatchedLogs.conversationId],   references: [conversations.id] }),
  suggestedIntent: one(nluIntents,    { fields: [nluUnmatchedLogs.suggestedIntentId], references: [nluIntents.id] }),
  resolvedIntent:  one(nluIntents,    { fields: [nluUnmatchedLogs.resolvedIntentId],  references: [nluIntents.id], relationName: 'resolvedIntent' }),
  resolvedBy:      one(operatorUsers, { fields: [nluUnmatchedLogs.resolvedBy],         references: [operatorUsers.id] }),
}))

export const apiPartnersRelations = relations(apiPartners, ({ one, many }) => ({
  approvedBy: one(operatorUsers, { fields: [apiPartners.approvedBy], references: [operatorUsers.id] }),
  usageLogs:  many(apiUsageLogs),
}))

export const apiUsageLogsRelations = relations(apiUsageLogs, ({ one }) => ({
  partner: one(apiPartners, { fields: [apiUsageLogs.partnerId], references: [apiPartners.id] }),
  service: one(services,    { fields: [apiUsageLogs.serviceId], references: [services.id] }),
  flow:    one(flows,       { fields: [apiUsageLogs.flowId],    references: [flows.id] }),
}))

export const webhookOutboundLogsRelations = relations(webhookOutboundLogs, ({ one }) => ({
  conversation: one(conversations, { fields: [webhookOutboundLogs.conversationId], references: [conversations.id] }),
}))

export const provincesRelations = relations(provinces, ({ many }) => ({
  municipalities: many(municipalities),
  locations:      many(locations),
  conversations:  many(conversations),
}))

export const municipalitiesRelations = relations(municipalities, ({ one, many }) => ({
  province:      one(provinces, { fields: [municipalities.provinceId], references: [provinces.id] }),
  locations:     many(locations),
  conversations: many(conversations),
}))


// ═════════════════════════════════════════════
// TYPES
// ═════════════════════════════════════════════
export type OperatorUser             = typeof operatorUsers.$inferSelect
export type NewOperatorUser          = typeof operatorUsers.$inferInsert
export type OperatorSession          = typeof operatorSessions.$inferSelect
export type NewOperatorSession       = typeof operatorSessions.$inferInsert
export type ApiPartner               = typeof apiPartners.$inferSelect
export type NewApiPartner            = typeof apiPartners.$inferInsert
export type ApiUsageLog              = typeof apiUsageLogs.$inferSelect
export type NewApiUsageLog           = typeof apiUsageLogs.$inferInsert
export type OperatorNotification     = typeof operatorNotifications.$inferSelect
export type NewOperatorNotification  = typeof operatorNotifications.$inferInsert
export type Province                 = typeof provinces.$inferSelect
export type NewProvince              = typeof provinces.$inferInsert
export type Municipality             = typeof municipalities.$inferSelect
export type NewMunicipality          = typeof municipalities.$inferInsert
export type Ministry                 = typeof ministries.$inferSelect
export type NewMinistry              = typeof ministries.$inferInsert
export type Service                  = typeof services.$inferSelect
export type NewService               = typeof services.$inferInsert
export type ServiceDocument          = typeof serviceDocuments.$inferSelect
export type NewServiceDocument       = typeof serviceDocuments.$inferInsert
export type ServiceStep              = typeof serviceSteps.$inferSelect
export type NewServiceStep           = typeof serviceSteps.$inferInsert
export type ServiceVersion           = typeof serviceVersions.$inferSelect
export type NewServiceVersion        = typeof serviceVersions.$inferInsert
export type Flow                     = typeof flows.$inferSelect
export type NewFlow                  = typeof flows.$inferInsert
export type FlowStep                 = typeof flowSteps.$inferSelect
export type NewFlowStep              = typeof flowSteps.$inferInsert
export type Location                 = typeof locations.$inferSelect
export type NewLocation              = typeof locations.$inferInsert
export type ServiceLocation          = typeof serviceLocations.$inferSelect
export type NewServiceLocation       = typeof serviceLocations.$inferInsert
export type Conversation             = typeof conversations.$inferSelect
export type NewConversation          = typeof conversations.$inferInsert
export type ConversationMessage      = typeof conversationMessages.$inferSelect
export type NewConversationMessage   = typeof conversationMessages.$inferInsert
export type ConversationFeedback     = typeof conversationFeedback.$inferSelect
export type NewConversationFeedback  = typeof conversationFeedback.$inferInsert
export type NluIntent                = typeof nluIntents.$inferSelect
export type NewNluIntent             = typeof nluIntents.$inferInsert
export type NluTrainingPhrase        = typeof nluTrainingPhrases.$inferSelect
export type NewNluTrainingPhrase     = typeof nluTrainingPhrases.$inferInsert
export type NluUnmatchedLog          = typeof nluUnmatchedLogs.$inferSelect
export type NewNluUnmatchedLog       = typeof nluUnmatchedLogs.$inferInsert
export type AnalyticsDailySnapshot   = typeof analyticsDailySnapshots.$inferSelect
export type NewAnalyticsDailySnapshot = typeof analyticsDailySnapshots.$inferInsert
export type SearchAnalytic           = typeof searchAnalytics.$inferSelect
export type NewSearchAnalytic        = typeof searchAnalytics.$inferInsert
export type WebhookEvent             = typeof webhookEvents.$inferSelect
export type NewWebhookEvent          = typeof webhookEvents.$inferInsert
export type WebhookOutboundLog       = typeof webhookOutboundLogs.$inferSelect
export type NewWebhookOutboundLog    = typeof webhookOutboundLogs.$inferInsert
export type ActivityLog              = typeof activityLog.$inferSelect
export type NewActivityLog           = typeof activityLog.$inferInsert

About

O Balcão AI é um assistente de serviços públicos via WhatsApp O cidadão escreve o que precisa em linguagem natural. O sistema identifica o serviço exacto, devolve a lista completa de documentos ("NIF impresso, não digital"), o local, o horário, o custo actualizado e — se for um processo multi-etapa a sequência completa ordenada.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages