Documento vivo. O cérebro do projecto — orientado a fluxo, jornada do utilizador e propriedade de dados. Tudo parte daqui.
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.
| 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.
| 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) |
[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.
[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
[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
[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
[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
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.
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.
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.
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).
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.
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.
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.
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.
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.
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
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
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
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)
- 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.
| Decisão | Escolha | Motivo |
|---|---|---|
| Canal principal | 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 |
| 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 |
| 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 |
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