D-1a — BFF (Backend for Frontend)
Parte da D-1a — Captura / D-1 — Ingestão de Demanda
Propósito
Seção intitulada “Propósito”A D-1a é o ponto de entrada do sistema. Toda interação com o cidadão começa aqui. Recebe dois tipos de input via API HTTP: demandas públicas e lugares. Também atende um fluxo de autenticação opcional via Google OAuth. Valida campos mínimos obrigatórios, aplica rate limiting e bounding box territorial, persiste o dado bruto no estado próprio e publica eventos no barramento. A partir do evento publicado, a cadeia de colônias é disparada sem que a D-1a conheça ou invoque qualquer uma delas.
Não normaliza, não categoriza, não georreferencia, não prioriza. A responsabilidade termina com o evento publicado e o ID devolvido ao cidadão para acompanhamento.
No MVP, a D-1a também hospeda o cadastro básico de cidadãos: identidade via Device ID anônimo (UUID v4) com upgrade voluntário para login Google. O dispositivo anônimo comprova a posse por um segredo gerado no navegador e enviado no header X-Device-Segredo; a API guarda apenas o hash SHA-256. Na Fase 2, a responsabilidade de cadastro migra para a C-1 (Cadastro de Cidadãos), microsserviço independente. O código do MVP isola o repositório de cidadãos com interface própria, sem acoplamento com o restante do módulo, preparado para extração futura.
A D-1a também é o único ponto de escrita do fluxo do conselheiro. Os três POSTs de conselheiros publicam eventos no barramento; as leituras do workspace ficam com as colônias donas do dado (D-6a e D-6b).
A D-1a tem dois documentos irmãos de camadas distintas:
| Camada | Responsabilidade | Documento |
|---|---|---|
| Front-end | App React/Vite: mapa, fluxos de adição de demanda e lugar, formulários de captura | D-1a - Front-end.md |
| BFF | Servidor NestJS acoplado: validação, persistência do dado bruto, publicação de eventos, gestão de identidade | Este documento |
A separação é de runtime. O front-end é uma aplicação cliente independente, e o BFF é um módulo NestJS dentro do monolito modular. Este documento especifica o BFF. O front-end é referenciado onde necessário para clareza de contrato, mas seu detalhamento está no documento próprio.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A D-1a é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Expõe controllers REST para o front-end e publica eventos no barramento via EventBusService (N-0a). Não consome eventos de outras colônias. É exclusivamente produtora.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/demanda/d-1a-captura/├── d1a.module.ts # @Module com OnModuleInit; chama CapturaService.iniciar()├── d1a.constants.ts # Bounding box, limites, TTL, enums e campos obrigatórios├── controllers/│ ├── demanda.controller.ts # POST /demandas│ ├── lugar.controller.ts # POST /lugares│ ├── auth.controller.ts # POST /auth/google│ ├── anexo.controller.ts # POST /anexos/presigned-url│ ├── cidadao.controller.ts # GET /cidadaos/me, PATCH /cidadaos/me│ ├── taxonomia.controller.ts # GET /taxonomia│ └── conselheiro.controller.ts # POST /conselheiros, recusar e atualizações├── services/│ ├── captura.service.ts # criarDemanda(), criarLugar(), iniciar() com a varredura de órfãos│ ├── cidadao.service.ts # Identidade anônima e Google, vínculo de dispositivo e perfil│ ├── auth.service.ts # Verificação do token Google OAuth│ ├── token.service.ts # Emissão e verificação do JWT próprio│ ├── taxonomia.service.ts # Taxonomia em memória, carregada no boot│ ├── anexo.service.ts # Presigned URLs e criação do bucket│ └── conselheiro.service.ts # Cadastro, recusa e atualização do conselheiro├── dto/│ ├── criar-demanda.dto.ts # Contrato POST /demandas│ ├── criar-lugar.dto.ts # Contrato POST /lugares│ ├── atualizar-perfil.dto.ts # Contrato PATCH /cidadaos/me│ ├── presigned-url-request.dto.ts # Contrato POST /anexos/presigned-url│ ├── google-auth.dto.ts # Shape do payload do Google ID token│ ├── auth-response.dto.ts # Resposta do login Google│ ├── perfil-cidadao.dto.ts # Resposta de GET/PATCH /cidadaos/me│ ├── cadastrar-conselheiro.dto.ts # Contrato POST /conselheiros│ ├── recusar-atribuicao.dto.ts # Contrato de recusa de atribuição│ └── registrar-atualizacao-conselheiro.dto.ts # Contrato POST /conselheiros/atualizacoes├── repositories/│ ├── cidadao.repository.ts # Acesso a d1a.cidadaos│ ├── demanda.repository.ts # Acesso a d1a.demandas_recebidas e transferência de titularidade│ ├── idempotencia-lugar.repository.ts # Acesso a d1a.idempotencia_lugares│ └── taxonomia.repository.ts # Leitura de core.taxonomia_* e validação de categoria└── guards/ └── rate-limit.guard.ts # ThrottlerGuard com tracker por IP1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [ ThrottlerModule.forRoot([ { ttl: 60000, // 1 minuto em ms limit: 1, // fallback: 1 requisição por minuto }, ]), ], controllers: [ DemandaController, LugarController, AuthController, AnexoController, CidadaoController, TaxonomiaController, ConselheiroController, ], providers: [ CapturaService, CidadaoService, AuthService, TokenService, TaxonomiaService, AnexoService, ConselheiroService, CidadaoRepository, DemandaRepository, IdempotenciaLugarRepository, TaxonomiaRepository, ConselheiroGuard, { provide: APP_GUARD, useClass: RateLimitGuard, // rate limiting global, com tracker por IP }, ], exports: [],})export class D1aModule implements OnModuleInit { constructor(private readonly capturaService: CapturaService) {}
async onModuleInit(): Promise<void> { await this.capturaService.iniciar(); // varredura de saídas órfãs }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A D-1a não é uma dependência de nenhuma outra colônia. Outras colônias consomem seus eventos, não seu código. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. A D-1a publica eventos, não os consome. - O módulo importa
ThrottlerModule.forRoot()com configuração de fallback. ORateLimitGuardé registrado comoAPP_GUARD, e cada rota sobrepõe o limite com@Throttle(). - A D-1a é o único módulo que faz
ThrottlerModule.forRoot(). Os parâmetros são os defaults do módulo. Os limites reais são por rota. - O módulo não registra
consumer_offset. A D-1a não consome eventos, portanto não precisa de cursor de replay. - O
D1aModuleimplementaOnModuleInite chamaCapturaService.iniciar(), que executa a varredura de órfãos no boot. - O
TaxonomiaServicecarrega as tabelascore.taxonomia_*(áreas, categorias com filtroativo, subcategorias) via Prisma noOnModuleInite monta o shape do grid. Não existe arquivoconfig/taxonomia.json. Na Fase 2, se a taxonomia se tornar dinâmica, o serviço é substituído por chamadas HTTP ao módulo de categorização.
1.4 Serviços — contratos implementados
Seção intitulada “1.4 Serviços — contratos implementados”Os serviços expõem os métodos reais listados abaixo. Na Fase 2, a extração da C-1 troca a implementação do repositório de cidadãos sem alterar estes contratos.
// CapturaServicecriarDemanda(dto: CriarDemandaDto, cidadaoId: string, canal: string): Promise<{ demanda_id: string }>;criarLugar(dto: CriarLugarDto, cidadaoId: string, canal: string): Promise<{ rastreamento_id: string }>;iniciar(): Promise<void>; // varredura de órfãos, executada no boot pelo módulo
// CidadaoServicebuscarPorId(cidadaoId: string): Promise<Cidadao | null>;findOrCreateAnonymous(deviceId: string, deviceSecret?: string): Promise<Cidadao>;verificarVinculoDispositivo(deviceId: string, deviceSecret?: string): Promise<void>;findOrCreateFromGoogle(payload: GoogleTokenPayload): Promise<Cidadao>;vincularDispositivos(cidadaoOrigemId: string, cidadaoDestinoId: string): Promise<void>;atualizarPerfil(cidadaoId: string, dto: AtualizarPerfilDto): Promise<Cidadao>;
// AuthServiceverificarGoogleToken(idToken: string): Promise<GoogleTokenPayload>;
// TokenServiceassinar(payload: PayloadToken): string;verificar(token: string): TokenVerificadoCidadao;
// TaxonomiaServicegetAreas(): AreaTematica[];getCategorias(areaId: string): CategoriaGrid[];
// AnexoServicegerarPresignedUrl(dto: PresignedUrlRequestDto): Promise<{ url: string; expires_in: number; object_key: string }>;
// ConselheiroServicecadastrar(dto: CadastrarConselheiroDto, cidadaoId: string): Promise<CadastroConselheiroResponse>;recusarAtribuicao(demandaId: string, dto: RecusarAtribuicaoDto, cidadaoId: string): Promise<RecusaAtribuicaoResponse>;registrarAtualizacao(dto: RegistrarAtualizacaoConselheiroDto, cidadaoId: string): Promise<AtualizacaoRegistradaResponse>;
// AssistenciaServicerevisarTexto(texto: string): Promise<RespostaAssistencia>;1.5 Controllers — endpoints expostos
Seção intitulada “1.5 Controllers — endpoints expostos”| Método | Rota | Controller | Descrição |
|---|---|---|---|
| POST | /api/demandas |
DemandaController | Cria demanda pública. Exige X-Cidadao-Id (UUID v4) e aceita X-Device-Segredo. Validação, idempotência, persistência e evento. |
| POST | /api/lugares |
LugarController | Cria lugar. Exige X-Cidadao-Id e aceita X-Device-Segredo. Validação, idempotência e publicação de evento. |
| POST | /api/auth/google |
AuthController | Login social. Valida o token Google, cria ou vincula cidadão e devolve JWT próprio. |
| POST | /api/anexos/presigned-url |
AnexoController | Gera URL pré-assinada para upload direto ao MinIO. |
| GET | /api/cidadaos/me |
CidadaoController | Retorna o perfil do cidadão autenticado por JWT. |
| PATCH | /api/cidadaos/me |
CidadaoController | Atualiza endereço e UC de residência. Exige JWT. |
| GET | /api/taxonomia |
TaxonomiaController | Retorna áreas temáticas com categorias e subcategorias para o grid de seleção do front-end. |
| POST | /api/conselheiros |
ConselheiroController | Cadastra o cidadão autenticado como conselheiro. Publica conselheiro.cadastrado 1.1.0. ConselheiroGuard (JWT Google). |
| POST | /api/conselheiros/atribuicoes/:demanda_id/recusar |
ConselheiroController | Recusa a atribuição ativa com motivo recusa_explicita ou risco_pessoal. Publica conselheiro.atribuicao_recusada 1.1.0. ConselheiroGuard. |
| POST | /api/conselheiros/atualizacoes |
ConselheiroController | Registra atualização do acompanhamento. Valida o tipo entre os seis valores da D-6b e os campos obrigatórios por tipo. Publica conselheiro.atualização_registrada 1.0.0. ConselheiroGuard. |
| POST | /api/assistencia/texto |
AssistenciaController | Revisa o texto de um campo do cidadão com o motor LanguageTool. Rota stateless e pública, sem identidade, com throttle de 10/min por IP. A falha do motor devolve disponivel: false e nunca bloqueia o envio do formulário. |
As três rotas de conselheiro aplicam throttle de 5/min. O ConselheiroGuard exige JWT com auth_provider = 'google' e injeta o cidadao_id na requisição; a identidade do conselheiro no MVP é o próprio cidadao_id.
A assistência de texto aceita os campos demanda.titulo, demanda.descricao, lugar.nome, lugar.descricao e empresa.descricao, com texto de 1 a 5000 caracteres. O DTO AssistenciaTextoDto valida campo e texto; a resposta traz disponivel, motor, versao e sugestoes, e o contrato de indisponibilidade responde HTTP 200 com disponivel: false e o motivo. Quando o cidadão aplica sugestões, o front envia o campo adicional assistencia no formulário, publicado como adicional em demanda.recebida, lugar.recebido e empresa.cadastrada. O campo registra usada, motor, versao e aplicadas, sem guardar o texto anterior nem o sugerido.
O nivel_atuacao do cadastro reflete o nível da UC escolhida no app (1 a 7). O contrato não muda: o campo já existia na 1.1.0 e continua sendo validado e publicado como recebido. A mudança é de preenchimento no front, que passa a enviar o nível da UC selecionada no lugar do valor fixo 1. A D-6a segue sem validar o nível contra o polígono e a unique (cidadao_id, unidade_civica_id, nivel_atuacao) continua barrando a duplicata exata.
1.6 Colônia com BFF acoplado
Seção intitulada “1.6 Colônia com BFF acoplado”A D-1a é uma colônia com BFF acoplado. Expõe endpoints REST diretamente para o front-end. Esta é a natureza do ponto de entrada: ele precisa de interface síncrona. As chamadas do BFF são operações de entrada (comandos do cidadão), não lógica de negócio delegada a outras colônias. A D-1a não atua como proxy para outras colônias no MVP. O endpoint de taxonomia serve dados de referência do schema core, disponíveis localmente.
O BFF é o único ponto de escrita do fluxo do conselheiro. Cadastro, recusa e atualizações chegam pelos três POSTs de conselheiros e viram eventos no barramento (conselheiro.cadastrado, conselheiro.atribuicao_recusada, conselheiro.atualização_registrada). O BFF valida os DTOs e publica com retry; não consulta estado de outra colônia e não decide sobre os dados. As leituras do workspace ficam nos controllers das colônias donas do dado (D-6a para situação e D-6b para acompanhamento), na exceção única do AGENTS.md da API.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d1a
Seção intitulada “2.1 Schema d1a”Todas as tabelas da D-1a residem no schema d1a do PostgreSQL. Este schema é de uso exclusivo do módulo D-1a. Nenhuma outra colônia lê ou escreve nestas tabelas. Na Fase 2, a tabela cidadaos é extraída para o schema c1. O código do MVP já isola o repositório com interface própria para essa migração futura.
2.2 Tabela d1a.cidadaos
Seção intitulada “2.2 Tabela d1a.cidadaos”Fonte da verdade da identidade do cidadão no MVP. Na Fase 2, extraída para a C-1.
CREATE TABLE "d1a"."cidadaos" ( "id" UUID NOT NULL, "nome" VARCHAR(200), "email" VARCHAR(255), "avatar_url" VARCHAR(500), "auth_provider" VARCHAR(20) NOT NULL DEFAULT 'anonymous', "google_sub" VARCHAR(255), "endereco" VARCHAR(500), "uc_residencia" UUID, "device_secret_hash" VARCHAR(64), "criado_em" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, "atualizado_em" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "cidadaos_pkey" PRIMARY KEY ("id"));
CREATE UNIQUE INDEX "cidadaos_google_sub_key" ON "d1a"."cidadaos"("google_sub");CREATE INDEX "cidadaos_auth_provider_idx" ON "d1a"."cidadaos"("auth_provider");CREATE INDEX "cidadaos_google_sub_idx" ON "d1a"."cidadaos"("google_sub");Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | cidadão_id usado em todo o sistema. No cadastro anônimo é o Device ID (UUID v4) enviado pelo app; no login Google é um UUID v4 gerado pelo BFF. |
nome |
VARCHAR(200) | Do Google. Nulo para device anônimo. |
email |
VARCHAR(255) | Do Google. Nulo para device anônimo. |
avatar_url |
VARCHAR(500) | URL do avatar do Google. Nulo para device anônimo. |
auth_provider |
VARCHAR(20) | anonymous ou google. Validação em aplicação; sem CHECK no banco. |
google_sub |
VARCHAR(255) UNIQUE | ID único do Google (sub do token). Nulo para anônimo. Índice único cobre todos os valores não nulos e nulos. |
endereco |
VARCHAR(500) | Endereço autodeclarado. Nulo até o cidadão preencher. |
uc_residencia |
UUID | unidade_civica_id de moradia autodeclarada. Nulo até o cidadão preencher. |
device_secret_hash |
VARCHAR(64) | SHA-256 do segredo de dispositivo gerado no navegador. Comprova a posse do dispositivo no vínculo do login Google. Nulo para contas Google sem dispositivo vinculado. |
criado_em |
TIMESTAMPTZ(2) | Data de criação do registro. |
atualizado_em |
TIMESTAMPTZ(2) | Data da última alteração. |
2.3 Tabela d1a.demandas_recebidas
Seção intitulada “2.3 Tabela d1a.demandas_recebidas”Registro append-only das demandas brutas recebidas. Status pendente_normalizacao até a D-1b processar.
CREATE TABLE "d1a"."demandas_recebidas" ( "id" UUID NOT NULL, "cidadao_id" UUID NOT NULL, "texto_bruto" TEXT NOT NULL, "tipo_midia" VARCHAR(10), "localizacao_lat" DOUBLE PRECISION NOT NULL, "localizacao_lng" DOUBLE PRECISION NOT NULL, "categoria_id" VARCHAR(20), "subcategoria_id" VARCHAR(50), "titulo" VARCHAR(200), "descricao" TEXT, "midia_urls" JSONB NOT NULL DEFAULT '[]', "assistencia" JSONB, "canal" VARCHAR(10) NOT NULL, "status" VARCHAR(30) NOT NULL DEFAULT 'pendente_normalizacao', "idempotencia_hash" VARCHAR(64) NOT NULL, "evento_publicado_em" TIMESTAMPTZ(2), "criado_em" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "demandas_recebidas_pkey" PRIMARY KEY ("id"));
CREATE UNIQUE INDEX "demandas_recebidas_idempotencia_hash_key" ON "d1a"."demandas_recebidas"("idempotencia_hash");CREATE INDEX "demandas_recebidas_cidadao_id_idx" ON "d1a"."demandas_recebidas"("cidadao_id");CREATE INDEX "demandas_recebidas_status_idx" ON "d1a"."demandas_recebidas"("status");Os CHECKs de canal, status e tipo_midia não existem no banco. Prisma não gera CHECK. A validação de enums ocorre em aplicação, nos DTOs e nos controllers. O canal é fixo 'app' nos controllers.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | demanda_id. Gerado pela aplicação, não pelo banco. Para idempotência controlada. |
cidadao_id |
UUID | Cidadão que submeteu a demanda. FK lógica, sem constraint: o cidadão pode ser deletado na migração da Fase 2. |
texto_bruto |
TEXT | Texto sintetizado: [título]. [descrição]. Se ambos vazios, string vazia. Campo texto_bruto do evento demanda.recebida. |
tipo_midia |
VARCHAR(10) | texto, foto ou audio. Determinado pelo BFF com base no payload. O controller exige texto ou mídia, então o caminho validado sempre publica um tipo. |
localizacao_lat |
DOUBLE PRECISION | Latitude fornecida pelo dispositivo. Validada contra bounding box. |
localizacao_lng |
DOUBLE PRECISION | Longitude fornecida pelo dispositivo. Validada contra bounding box. |
categoria_id |
VARCHAR(20) | Categoria selecionada pelo cidadão no fluxo de captura (ex: 1.1). Metadado de captura, validado contra core.taxonomia_categorias e publicado no evento 1.2.0. Não é categorização do sistema. |
subcategoria_id |
VARCHAR(50) | Subcategoria selecionada pelo cidadão. Mesmo tratamento de categoria_id. |
titulo |
VARCHAR(200) | Título pré-preenchido ou editado pelo cidadão. |
descricao |
TEXT | Texto livre opcional digitado pelo cidadão. |
midia_urls |
JSONB | Array de { tipo: "imagem" | "audio", url: "...", object_key?: "..." }. URLs já apontando para o storage (upload feito pelo front-end antes do POST). Vídeo ficou fora do contrato da Fase 1. |
assistencia |
JSONB | Auditoria de uso do revisor de texto: { usada, motor, versao, aplicadas }. Nulo quando o cidadão não aplicou sugestões. Não guarda o texto anterior nem o sugerido; o texto publicado é o texto final do cidadão. |
canal |
VARCHAR(10) | Canal de entrada. No MVP os controllers fixam 'app'. |
status |
VARCHAR(30) | pendente_normalizacao (default), normalizada (após D-1b), erro. |
idempotencia_hash |
VARCHAR(64) UNIQUE | Hash SHA-256 do conteúdo determinístico da demanda. Garante idempotência. |
evento_publicado_em |
TIMESTAMPTZ(2) | Preenchido após publicação bem-sucedida de demanda.recebida. Nulo identifica saída órfã para a varredura do boot. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação no banco. |
2.4 Tabela d1a.idempotencia_lugares
Seção intitulada “2.4 Tabela d1a.idempotencia_lugares”Registro exclusivo de idempotência para o fluxo de lugares. O write model do lugar pertence à L-1. Contém o hash de idempotência, o rastreamento_id devolvido ao cidadão para acompanhamento e o payload do evento, usado pela varredura de republicação de saídas órfãs no boot.
CREATE TABLE "d1a"."idempotencia_lugares" ( "idempotencia_hash" VARCHAR(64) NOT NULL, "rastreamento_id" UUID NOT NULL, "payload" JSONB, "evento_publicado_em" TIMESTAMPTZ(2), "criado_em" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "idempotencia_lugares_pkey" PRIMARY KEY ("idempotencia_hash"));
CREATE INDEX "idempotencia_lugares_criado_em_idx" ON "d1a"."idempotencia_lugares"("criado_em");Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
idempotencia_hash |
VARCHAR(64) PK | Hash SHA-256 do conteúdo determinístico da requisição. Garante idempotência. |
rastreamento_id |
UUID | UUID v4 gerado pelo BFF. Devolvido ao cidadão para acompanhamento. Usado como event_id e correlacao_id na publicação do evento. Não é o lugar_id definitivo, que é gerado pela L-1. |
payload |
JSONB | Payload de lugar.recebido persistido no momento do insert. Usado pela varredura de órfãos para republicar sem reconstruir o evento. Nulo em registros anteriores à migration que criou a coluna. |
evento_publicado_em |
TIMESTAMPTZ(2) | Preenchido após publicação bem-sucedida de lugar.recebido. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação. |
2.5 Migrations
Seção intitulada “2.5 Migrations”As migrations que criam e evoluem o schema d1a:
20260809101544_create_d1a_schema— Cria o schemad1ae as tabelascidadaos,demandas_recebidaseidempotencia_lugares, com índices e a unique constraint de idempotência.20260814191025_add_payload_idempotencia_lugares— Adiciona a colunapayloademidempotencia_lugares.20260910153000_d1a_cidadao_device_secret_hash— Adiciona a colunadevice_secret_hashemcidadaos.0008_d1a_assistencia— Adiciona a colunaassistenciaemdemandas_recebidas.
2.6 Relações internas
Seção intitulada “2.6 Relações internas”Não há foreign keys entre as tabelas do schema d1a. O cidadao_id em demandas_recebidas é uma FK lógica. A constraint formal não é criada porque na Fase 2 a tabela cidadaos migra para outro schema. A integridade é garantida em aplicação: os controllers de demanda e lugar chamam CidadaoService.findOrCreateAnonymous() antes de gravar, o que valida o Device ID (UUID v4) e o segredo do dispositivo.
2.7 Decisões de schema
Seção intitulada “2.7 Decisões de schema”idempotencia_hash como UNIQUE, não o id.
O id (UUID v4) é aleatório e não determinístico. Se o front-end retryar um POST, um novo UUID seria gerado e o banco criaria uma segunda linha. O idempotencia_hash é derivado do conteúdo determinístico da requisição. Mesmo payload gera o mesmo hash. A constraint UNIQUE no hash garante que retrys não criem duplicatas, independentemente do UUID.
texto_bruto como TEXT, não VARCHAR.
O texto livre do cidadão pode ser longo (descrição detalhada). TEXT no PostgreSQL não tem limite prático (até 1 GB). O DTO de entrada aplica limite de 5000 caracteres. O campo no banco aceita mais para evitar perda de dado se o limite de validação for aumentado no futuro.
Lat/lng como DOUBLE PRECISION, não PostGIS geometry.
A validação geoespacial no MVP é feita por bounding box (quatro comparações numéricas), não por point-in-polygon. Essa operação é responsabilidade da D-2. Duas colunas DOUBLE PRECISION são indexáveis, legíveis diretamente em SQL e não exigem extensão PostGIS no schema d1a. Na Fase 2, a D-1a pode adotar PostGIS se a validação de entrada se tornar geoespacialmente precisa.
midia_urls como JSONB, não tabela separada.
Anexos são gerenciados pela D-1c. A D-1a apenas registra as URLs fornecidas pelo front-end. Uma coluna JSONB é suficiente para o array de { tipo, url, object_key? }. Sem necessidade de tabela de junção ou FK para anexos. O ciclo de vida dos arquivos é domínio da D-1c.
device_secret_hash como prova de posse do dispositivo.
O segredo é gerado no navegador no primeiro acesso e enviado no header X-Device-Segredo. A API guarda apenas o hash SHA-256 e nunca o valor original. O hash comprova a posse do dispositivo no vínculo do login Google, fechando a troca de identidade por X-Cidadao-Id forjado. O comprimento mínimo do segredo é 16 caracteres.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A D-1a é exclusivamente produtora de eventos. Não consome nenhum. Os contratos descritos aqui são o que a D-1a publica no barramento via EventBusService.publicar(). Os schemas completos (JSON Schema draft 2020-12) estão definidos no Registry (N-0b).
3.1 demanda.recebida
Seção intitulada “3.1 demanda.recebida”| Propriedade | Valor |
|---|---|
| Tipo | demanda.recebida |
| Schema versions | 1.0.0, 1.1.0 e 1.2.0 no Registry. O BFF publica a 1.2.0. |
| Produtor | D-1a |
| Consumidores | D-1b (Normalização), D-1c (Anexos) |
| Descrição | Input bruto do cidadão aceito pelo sistema. |
Payload publicado:
interface DemandaRecebidaPayload { demanda_id: string; texto_bruto: string; // sintetizado de título + descrição; vazio na captura só com mídia tipo_midia: string; // 'texto' | 'foto' | 'audio' localizacao_bruta: { lat: number; lng: number; }; midia_urls: Array<{ // campo adicional ao schema; validação Ajv não usa additionalProperties: false tipo: 'imagem' | 'audio'; // contrato literal; vídeo ficou fora da Fase 1 url: string; object_key?: string; }>; cidadao_id: string; canal: string; // os controllers enviam 'app' timestamp_criacao: string; // ISO-8601 do dispositivo ou do servidor termos_versao?: string; // versão do termo aceito no dispositivo consentimentos?: Array<{ // consentimentos granulares por finalidade finalidade: 'relato_pessoal' | 'publicacao_conteudo' | 'midia_sensivel'; versao: string; aceito_em?: string; }>; assistencia?: { // campo adicional ao schema; auditoria de uso do revisor de texto usada: boolean; motor: string; // 'languagetool' versao: string; // 'pt-BR' aplicadas: number; }; categoria_id?: string; // campo da versão 1.1.0 subcategoria_id?: string; // campo da versão 1.1.0}A versão 1.1.0 adiciona categoria_id e subcategoria_id ao schema, de forma compatível com a 1.0.0. A versão 1.2.0 aceita captura só com mídia: texto_bruto pode ser vazio, e o título e a descrição continuam vindo exclusivamente do texto do cidadão quando ele existe. termos_versao e consentimentos existem desde a 1.0.0. midia_urls e assistencia são campos adicionais em todas as versões. O campo assistencia não contém dado pessoal e não entra nas regras de redação; a N-0b documenta o adicional nos três eventos.
3.2 lugar.recebido
Seção intitulada “3.2 lugar.recebido”| Propriedade | Valor |
|---|---|
| Tipo | lugar.recebido |
| Schema version | 1.0.0 |
| Produtor | D-1a |
| Consumidores | L-1 (Cadastro de Lugares) |
| Descrição | Input bruto de lugar submetido pelo cidadão. A L-1 é responsável por gerar o lugar_id, validar regras de negócio e publicar lugar.cadastrado. |
Payload publicado:
interface LugarRecebidoPayload { tipo_lugar: string; // 'residencia' | 'organizacao' | 'equipamento_publico' | 'poligono_uc' subtipo?: string; nome?: string; descricao?: string; // schema limita a 2000 caracteres posicao: { lat: number; lng: number; }; horario_funcionamento?: string; geometria?: Record<string, unknown>; // campo adicional; GeoJSON para poligonos de UC cidadao_id: string; canal: string; // os controllers enviam 'app' timestamp_criacao: string; assistencia?: { // campo adicional ao schema; auditoria do revisor de texto usada: boolean; motor: string; // 'languagetool' versao: string; // 'pt-BR' aplicadas: number; };}O schema do Registry exige tipo_lugar, posicao e cidadao_id, e limita descricao a 2000 caracteres. O DTO aplica o mesmo limite. assistencia é campo adicional, no mesmo padrão de midia_urls.
3.3 cidadão.cadastrado
Seção intitulada “3.3 cidadão.cadastrado”| Propriedade | Valor |
|---|---|
| Tipo | cidadão.cadastrado |
| Schema version | 1.0.0 |
| Produtor | D-1a (MVP) / C-1 (Fase 2) |
| Consumidores | D-7 (Transparência), colônias com projeção de perfil |
| Descrição | Novo cidadão registrado. Identidade básica estabelecida. |
Payload publicado:
interface CidadaoCadastradoPayload { cidadao_id: string; auth_provider: string; // 'anonymous' | 'google' nome?: string; // opcional no schema; o BFF não envia email?: string; // opcional no schema; o BFF não envia avatar_url?: string; // opcional no schema; o BFF não envia}O BFF publica apenas cidadao_id e auth_provider nos dois fluxos (anônimo e Google). Os metadados do perfil não vão no evento.
3.4 cidadão.perfil_atualizado
Seção intitulada “3.4 cidadão.perfil_atualizado”| Propriedade | Valor |
|---|---|
| Tipo | cidadão.perfil_atualizado |
| Schema version | 1.0.0 |
| Produtor | D-1a (MVP) / C-1 (Fase 2) |
| Consumidores | Colônias com projeção local de perfil |
| Descrição | Cidadão alterou dados de perfil. |
Payload publicado:
interface CidadaoPerfilAtualizadoPayload { cidadao_id: string; campos_alterados: string[]; // ex: ['endereco', 'uc_residencia'] uc_residencia?: string; // UUID da UC de residência, enviado quando preenchido}O schema admite nome, email, avatar_url e endereco como opcionais, mas o BFF só altera endereco e uc_residencia por essa rota. Quando nenhum campo muda, o serviço não publica evento.
3.5 cidadão.vinculado
Seção intitulada “3.5 cidadão.vinculado”| Propriedade | Valor |
|---|---|
| Tipo | cidadão.vinculado |
| Schema version | 1.0.0 |
| Produtor | D-1a (MVP) / C-1 (Fase 2) |
| Consumidores | D-7 (Transparência), D-12 (detecção de duplicidade), colônias com projeção de perfil |
| Descrição | Device ID anônimo vinculado a uma conta Google. |
Payload publicado:
interface CidadaoVinculadoPayload { cidadao_id_origem: string; // Device ID anônimo que foi vinculado cidadao_id_destino: string; // ID da conta Google auth_provider: string; // 'google'}A D-12 consome esse evento para fundir relator, confirmações e conclusões do device na conta Google, com deduplicação.
3.6 conselheiro.cadastrado, conselheiro.atribuicao_recusada e conselheiro.atualização_registrada
Seção intitulada “3.6 conselheiro.cadastrado, conselheiro.atribuicao_recusada e conselheiro.atualização_registrada”As três rotas de conselheiro publicam eventos com publicarComRetry e correlacao_id próprio:
| Evento | Versão | Campos principais |
|---|---|---|
conselheiro.cadastrado |
1.1.0 | conselheiro_id (o cidadao_id no MVP), cidadao_id, unidade_civica_id, nivel_atuacao, status: 'disponivel', capacitacao_concluida |
conselheiro.atribuicao_recusada |
1.1.0 | conselheiro_id, demanda_id, unidade_civica_id, motivo (recusa_explicita ou risco_pessoal), timestamp |
conselheiro.atualização_registrada |
1.1.0 com áudio, 1.0.0 no relato digitado | conselheiro_id, demanda_id, unidade_civica_id, tipo, texto_bruto, texto_estruturado, audio_object_key opcional, conteudo_estruturado, origem_estruturacao, timestamp, sugestao_ia opcional |
O cadastro exige capacitacao_concluida = true. A atualização valida o tipo contra os seis valores da D-6b e os campos obrigatórios de cada tipo. Quando origem_estruturacao = 'ia_assistida', sugestao_ia é obrigatória. Os schemas completos estão no N-0b, seções 3.4.10, 3.4.17 e 3.4.18.
Relato por áudio. O DTO aceita audio_object_key opcional. Com a chave, texto_bruto e texto_estruturado podem vir vazios, o BFF injeta a chave também em conteudo_estruturado e publica a versão 1.1.0; sem a chave, os dois textos exigem ao menos 1 caractere e o evento sai na 1.0.0. A conclusão de demanda (status_atualizado com novo_status = 'concluido') não aceita áudio e responde 400, porque o fluxo de conclusão usa texto. O envio responde na hora: a D-1b transcreve em segundo plano e a D-6b publica a atualização quando o texto fica pronto.
3.7 Ordem de operações — criar demanda
Seção intitulada “3.7 Ordem de operações — criar demanda”O fluxo começa no controller e segue para o CapturaService.criarDemanda():
1. Exigir o header X-Cidadao-Id → ausente: HTTP 400 "Header X-Cidadao-Id é obrigatório"
2. Validar o DTO de entrada → class-validator + class-transformer no pipe global → campos mínimos: localizacao_lat + localizacao_lng + (título ou descrição ou midia_urls não vazio) → se inválido: HTTP 400, sem efeito colateral
3. Garantir o cidadão anônimo → CidadaoService.findOrCreateAnonymous(cidadaoId, deviceSecret) → valida o UUID v4 e o segredo do dispositivo; cria o cidadão se não existir
4. Validar o bounding box → localizacao_lat entre LAT_MIN e LAT_MAX → localizacao_lng entre LNG_MIN e LNG_MAX → se fora: HTTP 400 "Coordenadas fora da área de operação"
5. Validar o raio de captura → campos opcionais fix_lat/fix_lng carregam a posição do GPS do dispositivo na captura → se o fix vier presente: distância Haversine entre o fix e a localização da demanda ≤ 10 km (RAIO_CAPTURA_LOCAL_METROS = 10000) → se acima de 10 km: HTTP 400 "Localização fora do raio de 10 km da posição do dispositivo" → sem fix (cliente antigo ou item antigo da fila offline): validação ignorada
6. Validar a categoria, quando enviada → consulta core.taxonomia_categorias; categoria inexistente: HTTP 400 "Categoria inválida" → categoria existente porém inativa é aceita
7. Validar as URLs de mídia, quando houver → host permitido derivado de MINIO_ENDPOINT e MINIO_PUBLIC_ENDPOINT; http só para hosts locais → host fora da allowlist: HTTP 400 "URL de mídia fora dos hosts permitidos"
8. Verificar idempotência → gerar idempotencia_hash (SHA-256 de cidadao_id + tipo + título + descrição + lat/lng) → consultar d1a.demandas_recebidas WHERE idempotencia_hash = ? → se encontrado: HTTP 409 { statusCode, demanda_id, duplicata: true, message }, sem publicar evento
9. Gerar demanda_id → UUID v4
10. Sintetizar texto_bruto → [título] + (título e descrição ? ". " : "") + [descrição] → truncar em 5000 caracteres se exceder
11. PERSISTIR no estado próprio → INSERT INTO d1a.demandas_recebidas (...) → violação de unicidade em corrida: relê pelo idempotencia_hash e responde como duplicata
12. PUBLICAR evento no barramento → publicarComRetry(event_bus, { tipo: 'demanda.recebida', versao_schema: '1.2.0', event_id: demanda_id, correlacao_id: demanda_id, payload }) → 4 tentativas com backoff de 500/1000/2000ms → falha definitiva: lança ErroPublicacaoEvento, HTTP 500
13. Atualizar evento_publicado_em → UPDATE d1a.demandas_recebidas SET evento_publicado_em = NOW() WHERE id = ?
14. Retornar 201 { statusCode: 201, demanda_id }Persistir antes de publicar garante que o dado bruto esteja salvo mesmo se o barramento falhar. O registro fica com evento_publicado_em = NULL e a varredura de órfãos do boot republica a saída pendente. Não há scheduled job.
3.8 Ordem de operações — criar lugar
Seção intitulada “3.8 Ordem de operações — criar lugar”O fluxo no controller e no CapturaService.criarLugar():
1. Exigir o header X-Cidadao-Id → ausente: HTTP 400 "Header X-Cidadao-Id é obrigatório"
2. Validar o DTO de entrada → campos mínimos: tipo_lugar + localizacao_lat + localizacao_lng → se inválido: HTTP 400, sem efeito colateral
3. Garantir o cidadão anônimo → CidadaoService.findOrCreateAnonymous(cidadaoId, deviceSecret)
4. Validar o bounding box → se fora: HTTP 400 "Coordenadas fora da área de operação"
5. Validar o raio de captura → mesma regra da demanda, fix opcional de até 10 km
6. Verificar idempotência → gerar idempotencia_hash (SHA-256 de cidadao_id + 'lugar' + tipo_lugar + nome + lat/lng) → consultar d1a.idempotencia_lugares WHERE idempotencia_hash = ? → se encontrado: HTTP 409 { statusCode, rastreamento_id, duplicata: true, message }
7. Gerar rastreamento_id → UUID v4 devolvido ao cidadão; o lugar_id definitivo é gerado pela L-1
8. Montar o payload de lugar.recebido → tipo_lugar, subtipo, nome, descricao, posicao, horario_funcionamento, geometria, cidadao_id, canal, timestamp_criacao
9. PERSISTIR hash, rastreamento_id e payload → INSERT INTO d1a.idempotencia_lugares (idempotencia_hash, rastreamento_id, payload) → violação de unicidade em corrida: relê pelo idempotencia_hash e responde como duplicata
10. PUBLICAR evento no barramento → publicarComRetry(event_bus, { tipo: 'lugar.recebido', event_id: rastreamento_id, correlacao_id: rastreamento_id, payload }) → falha definitiva: ErroPublicacaoEvento, HTTP 500
11. Atualizar evento_publicado_em → UPDATE d1a.idempotencia_lugares SET evento_publicado_em = NOW() WHERE idempotencia_hash = ?
12. Retornar 201 { statusCode: 201, rastreamento_id }O BFF não persiste o dado bruto do lugar. O write model é responsabilidade da L-1. O BFF armazena o hash de idempotência, o rastreamento_id e o payload do evento. Se o barramento falhar após o INSERT, o registro fica com evento_publicado_em = NULL e a varredura do boot republica a saída pendente.
3.9 Varredura de órfãos no boot
Seção intitulada “3.9 Varredura de órfãos no boot”O CapturaService.iniciar() é chamado pelo OnModuleInit do módulo e republica as saídas persistidas com evento_publicado_em IS NULL:
- Demandas: busca até 100 registros (
LIMITE_VARREDURA_ORFAOS), reconstrói o payload a partir do estado próprio e republica compublicarComRetry. O payload reconstruído não incluitermos_versao,consentimentos,categoria_idnemsubcategoria_id, que não são persistidos de forma separada. - Lugares: busca até 100 registros e republica o
payloadarmazenado. Registro sem payload (anterior à migration) gera log de warning e é ignorado. - Cada item republicado atualiza o
evento_publicado_em. Falha individual gera log de erro e não interrompe a varredura.
3.10 Ordem de operações — cadastro de cidadão (anônimo)
Seção intitulada “3.10 Ordem de operações — cadastro de cidadão (anônimo)”1. Front-end envia header X-Cidadao-Id (UUID v4 gerado na primeira abertura do app) e X-Device-Segredo (segredo local, mínimo de 16 caracteres)
2. CidadaoService.findOrCreateAnonymous(deviceId, deviceSecret) → valida o UUID v4; inválido: ErroDeviceIdInvalido (HTTP 400) → calcula o hash SHA-256 do segredo, quando presente e válido
→ se o cidadão existe: → com device_secret_hash gravado: exige o mesmo segredo; ausente ou diferente: ErroSegredoDispositivoInvalido (HTTP 403) → sem device_secret_hash e com segredo enviado: vincula o hash ao registro → sem device_secret_hash e sem segredo: retorna o cidadão
→ se não existe: → INSERT INTO d1a.cidadaos (id, auth_provider, device_secret_hash) VALUES (deviceId, 'anonymous', hash) → violação de unicidade em corrida: busca e reutiliza o registro criado → PUBLICAR cidadão.cadastrado payload: { cidadao_id: deviceId, auth_provider: 'anonymous' } erro de publicação: logado, não propagado
3. Resposta ao front-end: sem body adicional. O cidadão já tem o ID via header.3.11 Ordem de operações — login Google
Seção intitulada “3.11 Ordem de operações — login Google”1. Front-end obtém o Google ID token via OAuth 2.0 no navegador
2. POST /auth/google com Authorization: Bearer <google-id-token> e X-Cidadao-Id opcional (device anônimo a vincular) e X-Device-Segredo
3. AuthService.verificarGoogleToken(idToken) → google-auth-library verifica assinatura e expiração; valida audience quando GOOGLE_CLIENT_ID está configurada → exige email_verified = true → se inválido: HTTP 401 "Token Google inválido ou expirado"
4. CidadaoService.findOrCreateFromGoogle(payload) → consultar d1a.cidadaos WHERE google_sub = payload.sub → se encontrado: atualiza nome, email e avatar quando o Google trouxe valores novos; sem evento → se não encontrado: INSERT com auth_provider='google' e google_sub → PUBLICAR cidadão.cadastrado payload: { cidadao_id, auth_provider: 'google' } → erro de publicação: logado, não propagado
5. Se o front-end enviou X-Cidadao-Id diferente do cidadão retornado: → CidadaoService.verificarVinculoDispositivo(deviceId, deviceSecret) → sem segredo válido ou hash divergente: HTTP 403 "Segredo de dispositivo inválido" → DemandaRepository.transferirCidadao(deviceId, googleCidadaoId) → UPDATE d1a.demandas_recebidas SET cidadao_id = googleCidadaoId WHERE cidadao_id = deviceId → CidadaoService.vincularDispositivos(deviceId, googleCidadaoId) → verifica a existência do cidadão destino → PUBLICAR cidadão.vinculado payload: { cidadao_id_origem: deviceId, cidadao_id_destino: googleCidadaoId, auth_provider: 'google' }
6. Retornar 200 { cidadao_id, nome, email, auth_provider, token: <jwt> }O JWT próprio é gerado pelo TokenService (jsonwebtoken com JWT_SECRET) com expiração de JWT_EXPIRES_IN (default 7d) e os papéis do cidadão no claim roles. O front-end armazena e envia como Authorization: Bearer <jwt> nas requisições autenticadas.
3.12 Idempotência na publicação de eventos
Seção intitulada “3.12 Idempotência na publicação de eventos”A D-1a implementa duas camadas de idempotência, uma na aplicação e uma no barramento:
Camada 1 — Aplicação (D-1a).
O idempotencia_hash impede INSERTs duplicados no estado próprio. O hash é SHA-256 de conteúdo determinístico:
- Demanda:
cidadao_id + "|demanda|" + titulo + "|" + descricao + "|" + lat (6 casas) + "|" + lng (6 casas). - Lugar:
cidadao_id + "|lugar|" + tipo_lugar + "|" + nome + "|" + lat (6 casas) + "|" + lng (6 casas).
O hash não tem componente temporal. A idempotência é permanente: submissão idêntica do mesmo cidadão retorna HTTP 409 com o demanda_id (ou rastreamento_id) original, em qualquer momento. Submissões idênticas de cidadãos diferentes criam registros distintos.
A confirmação de demanda existente e a busca de candidatas não passam pela D-1a. Vivem na D-12 (POST /api/d12/demandas/:demanda_id/confirmacoes e POST /api/d12/candidatas). A agregação é resolvida pela D-12 por confirmação coletiva; o 409 da captura não foi estendido.
Camada 2 — Barramento (N-0a).
O EventBusService.publicar() usa event_id como chave de idempotência. Se a D-1a publicar um evento com o mesmo event_id, o barramento retorna o registro existente sem reemitir. O publicarComRetry reutiliza o mesmo event_id nas tentativas. O event_id do evento publicado é igual ao demanda_id/rastreamento_id, o que simplifica o rastreamento.
3.13 Tratamento de erro e reentrega
Seção intitulada “3.13 Tratamento de erro e reentrega”| Cenário | Comportamento |
|---|---|
Header X-Cidadao-Id ausente ou inválido |
HTTP 400. |
| Segredo de dispositivo ausente ou divergente | HTTP 403. |
| Falha de validação (campos mínimos, bounding box, raio, categoria, URL de mídia) | HTTP 400. Nenhum efeito colateral. |
| Rate limit excedido | HTTP 429. Retry-After header com segundos restantes. |
| Duplicata detectada (idempotencia_hash) | HTTP 409 com demanda_id ou rastreamento_id e duplicata: true. Nenhum evento publicado. |
| Violação de unicidade na corrida do INSERT | O serviço relê pelo hash de idempotência e responde 409 com o ID existente. |
| INSERT no banco falha por outro motivo | Erro de infraestrutura. HTTP 500. Nenhum evento publicado. |
| Publish no barramento falha após os retries | Erro logado. HTTP 500. Registro com evento_publicado_em = NULL, recuperado pela varredura do boot. |
| Google token inválido, expirado ou sem email verificado | HTTP 401. |
| Erro inesperado no handler | Capturado por exception filter global do NestJS. HTTP 500. |
3.14 Decisões de design com justificativa
Seção intitulada “3.14 Decisões de design com justificativa”Persistir antes de publicar.
O dado bruto fica salvo antes do evento ser emitido. Se o barramento falhar, o dado não foi perdido. O estado evento_publicado_em = NULL é recuperável pela varredura de órfãos no boot.
idempotencia_hash sem componente temporal.
O hash não usa timestamp. Submissões idênticas do mesmo cidadão retornam 409 permanente, em qualquer momento. Uma janela temporal foi descartada porque retries legítimos após a janela criavam duplicatas.
categoria_id e subcategoria_id armazenados e publicados.
O cidadão seleciona categorias no fluxo de captura (modelo Waze). A seleção é validada contra core.taxonomia_categorias e publicada no evento 1.2.0. A categorização do sistema pertence à D-3, que trabalha a partir do texto. A seleção do cidadão serve como metadado de captura e hint explícito.
Tabelas separadas para demandas e lugares. Demandas e lugares têm pipelines distintos após a D-1a: demandas vão para D-1b, lugares vão para L-1. Demandas são persistidas integralmente no BFF porque o pipeline a jusante depende do dado bruto para normalização. Lugares usam apenas idempotência no BFF, porque o write model pertence à L-1. Tabelas separadas refletem responsabilidades distintas.
Segredo de dispositivo e não apenas o Device ID.
O Device ID viaja em header e pode ser forjado por qualquer cliente. O segredo, gerado no navegador e guardado como hash, comprova a posse do dispositivo no vínculo do login Google. Sem a prova, um X-Cidadao-Id de terceiro poderia migrar demandas para outra conta. O segredo nunca é armazenado em claro.
3.15 Evento demanda.recebida — síntese do texto_bruto
Seção intitulada “3.15 Evento demanda.recebida — síntese do texto_bruto”O campo texto_bruto no payload do evento é sintetizado pelo BFF a partir dos campos do front-end:
texto_bruto = ''se dto.titulo não vazio: texto_bruto += dto.titulose dto.descricao não vazia: se texto_bruto não vazio: texto_bruto += '. ' texto_bruto += dto.descricao
// Truncar em 5000 caracteres (limite do schema do Registry)se texto_bruto.length > 5000: texto_bruto = texto_bruto.substring(0, 5000)O categoria_id e o subcategoria_id selecionados pelo cidadão não são concatenados ao texto_bruto. A taxonomia contém termos técnicos (ex: “Esgotamento sanitário”) que o cidadão não usaria naturalmente, e concatená-los poluiria o classificador da D-3 com viés de captura. A seleção viaja em campos próprios do evento desde a 1.1.0.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 CapturaService.criarDemanda() — pseudocódigo
Seção intitulada “4.1 CapturaService.criarDemanda() — pseudocódigo”função criarDemanda(dto: CriarDemandaDto, cidadaoId: string, canal: string) -> { demanda_id: string }:
// 1. Bounding box se dto.localizacao_lat < LAT_MIN ou dto.localizacao_lat > LAT_MAX ou dto.localizacao_lng < LNG_MIN ou dto.localizacao_lng > LNG_MAX: lançar ErroBoundingBox()
// 2. Raio de captura, quando o fix do GPS veio no payload fix = fixValido(dto.fix_lat, dto.fix_lng) se fix não é null: distancia = distanciaHaversineMetros(fix.lat, fix.lng, dto.localizacao_lat, dto.localizacao_lng) se distancia > RAIO_CAPTURA_LOCAL_METROS: lançar ErroForaDoRaio()
// 3. Categoria, quando enviada se dto.categoria_id não é undefined e não é null: se !taxonomiaRepo.categoriaExiste(dto.categoria_id): lançar ErroCategoriaInvalida()
// 4. URLs de mídia, quando houver validarUrlsMidia(dto.midia_urls) // host permitido via obterHostsMidiaPermitidos(); fora da allowlist: ErroMidiaUrlInvalida
// 5. Idempotência idempotenciaHash = SHA256( cidadaoId + "|demanda|" + (dto.titulo ?? "") + "|" + (dto.descricao ?? "") + "|" + dto.localizacao_lat.toFixed(6) + "|" + dto.localizacao_lng.toFixed(6) ) existente = demandaRepo.buscarPorIdempotenciaHash(idempotenciaHash) se existente não é null: lançar ErroDemandaDuplicada(existente.id)
// 6. Campos derivados demandaId = UUIDv4() textoBruto = sintetizarTextoBruto(dto) // truncado em 5000 tipoMidia = determinarTipoMidia(dto) // foto | audio | texto | null
// 7. Persistir no estado próprio // violação de unicidade em corrida: relê pelo hash e lança ErroDemandaDuplicada demandaRepo.inserir({ id: demandaId, cidadao_id: cidadaoId, texto_bruto: textoBruto, tipo_midia: tipoMidia, localizacao_lat: dto.localizacao_lat, localizacao_lng: dto.localizacao_lng, categoria_id: dto.categoria_id ?? null, subcategoria_id: dto.subcategoria_id ?? null, titulo: dto.titulo ?? null, descricao: dto.descricao ?? null, midia_urls: dto.midia_urls ?? [], canal: canal, status: 'pendente_normalizacao', idempotencia_hash: idempotenciaHash, })
// 8. Publicar evento com retry payloadEvento = { demanda_id, texto_bruto, tipo_midia, localizacao_bruta: { lat, lng }, midia_urls: dto.midia_urls ?? [], cidadao_id, canal, timestamp_criacao: dto.timestamp ?? new Date().toISOString(), } se dto.termos_versao: payloadEvento.termos_versao = dto.termos_versao se dto.consentimentos: payloadEvento.consentimentos = dto.consentimentos se dto.categoria_id: payloadEvento.categoria_id = dto.categoria_id se dto.subcategoria_id: payloadEvento.subcategoria_id = dto.subcategoria_id
tentar: publicarComRetry(eventBus, { tipo: 'demanda.recebida', origem: 'D-1a', versao_schema: '1.2.0', event_id: demandaId, correlacao_id: demandaId, payload: payloadEvento, }) demandaRepo.atualizarEventoPublicadoEm(demandaId, new Date()) capturar erro: logar erro "Falha ao publicar demanda.recebida" lançar ErroPublicacaoEvento()
retornar { demanda_id: demandaId }4.2 CapturaService.criarLugar() — pseudocódigo
Seção intitulada “4.2 CapturaService.criarLugar() — pseudocódigo”função criarLugar(dto: CriarLugarDto, cidadaoId: string, canal: string) -> { rastreamento_id: string }:
// 1. Bounding box se fora de LAT_MIN/LAT_MAX/LNG_MIN/LNG_MAX: lançar ErroBoundingBox()
// 2. Raio de captura, quando o fix do GPS veio no payload fix = fixValido(dto.fix_lat, dto.fix_lng) se fix não é null e distancia > RAIO_CAPTURA_LOCAL_METROS: lançar ErroForaDoRaio()
// 3. Idempotência idempotenciaHash = SHA256( cidadaoId + "|lugar|" + (dto.tipo_lugar ?? "") + "|" + (dto.nome ?? "") + "|" + dto.localizacao_lat.toFixed(6) + "|" + dto.localizacao_lng.toFixed(6) ) existente = lugarRepo.buscarPorHash(idempotenciaHash) se existente não é null: lançar ErroLugarDuplicado(existente.rastreamento_id)
// 4. Rastreamento e payload rastreamentoId = UUIDv4() payload = { tipo_lugar, subtipo, nome, descricao, posicao: { lat: dto.localizacao_lat, lng: dto.localizacao_lng }, horario_funcionamento, geometria, cidadao_id: cidadaoId, canal, timestamp_criacao: dto.timestamp ?? new Date().toISOString(), }
// 5. Persistir apenas a idempotência, com o payload para a varredura // violação de unicidade em corrida: relê pelo hash e lança ErroLugarDuplicado lugarRepo.inserir({ idempotencia_hash: idempotenciaHash, rastreamento_id: rastreamentoId, payload })
// 6. Publicar evento com retry tentar: publicarComRetry(eventBus, { tipo: 'lugar.recebido', origem: 'D-1a', event_id: rastreamentoId, correlacao_id: rastreamentoId, payload, }) lugarRepo.atualizarEventoPublicadoEm(idempotenciaHash, new Date()) capturar erro: logar erro "Falha ao publicar lugar.recebido" lançar ErroPublicacaoEvento()
retornar { rastreamento_id: rastreamentoId }4.3 CidadaoService.findOrCreateAnonymous() — pseudocódigo
Seção intitulada “4.3 CidadaoService.findOrCreateAnonymous() — pseudocódigo”função findOrCreateAnonymous(deviceId: string, deviceSecret?: string) -> Cidadao:
se !ehUuidV4(deviceId): lançar ErroDeviceIdInvalido(deviceId)
hashSegredo = segredoDispositivoValido(deviceSecret) ? SHA256(deviceSecret) : null // segredo válido tem no mínimo 16 caracteres
existente = cidadaoRepo.buscarPorId(deviceId)
se existente não é null: se existente.device_secret_hash não é null: se hashSegredo é null ou hashSegredo != existente.device_secret_hash: lançar ErroSegredoDispositivoInvalido() retornar existente se hashSegredo não é null: retornar cidadaoRepo.vincularSegredoDispositivo(deviceId, hashSegredo) retornar existente
tentar: cidadao = cidadaoRepo.inserir({ id: deviceId, auth_provider: 'anonymous', device_secret_hash: hashSegredo }) capturar erro: se violação de unicidade (P2002): criadoNaCorrida = cidadaoRepo.buscarPorId(deviceId) se criadoNaCorrida não é null: retornar criadoNaCorrida propagar
tentar: eventBus.publicar({ tipo: 'cidadão.cadastrado', origem: 'D-1a', event_id: deviceId, correlacao_id: deviceId, payload: { cidadao_id: deviceId, auth_provider: 'anonymous' } }) capturar erro: logar erro "Falha ao publicar cidadão.cadastrado (anônimo)"
retornar cidadao4.4 CidadaoService.findOrCreateFromGoogle() — pseudocódigo
Seção intitulada “4.4 CidadaoService.findOrCreateFromGoogle() — pseudocódigo”função findOrCreateFromGoogle(payload: GoogleTokenPayload) -> Cidadao:
existente = cidadaoRepo.buscarPorGoogleSub(payload.sub) se existente não é null: retornar cidadaoRepo.atualizar(existente.id, { nome: payload.name ?? existente.nome, email: payload.email ?? existente.email, avatar_url: payload.picture ?? existente.avatar_url, }) // atualizado_em é renovado pelo repositório; sem evento
novoId = UUIDv4() cidadao = cidadaoRepo.inserir({ id: novoId, auth_provider: 'google', nome: payload.name, email: payload.email, avatar_url: payload.picture, google_sub: payload.sub, })
tentar: eventBus.publicar({ tipo: 'cidadão.cadastrado', origem: 'D-1a', event_id: novoId, correlacao_id: novoId, payload: { cidadao_id: novoId, auth_provider: 'google' } }) capturar erro: logar erro "Falha ao publicar cidadão.cadastrado (Google)"
retornar cidadao4.5 CidadaoService.verificarVinculoDispositivo() e vincularDispositivos()
Seção intitulada “4.5 CidadaoService.verificarVinculoDispositivo() e vincularDispositivos()”função verificarVinculoDispositivo(deviceId: string, deviceSecret?: string): se !ehUuidV4(deviceId): lançar ErroDeviceIdInvalido(deviceId)
existente = cidadaoRepo.buscarPorId(deviceId) se existente é null: retornar // nada a comprovar
se !segredoDispositivoValido(deviceSecret): lançar ErroSegredoDispositivoInvalido()
hashSegredo = SHA256(deviceSecret) se existente.device_secret_hash não é null: se existente.device_secret_hash != hashSegredo: lançar ErroSegredoDispositivoInvalido() retornar
cidadaoRepo.vincularSegredoDispositivo(deviceId, hashSegredo)
função vincularDispositivos(cidadaoOrigemId: string, cidadaoDestinoId: string): destino = cidadaoRepo.buscarPorId(cidadaoDestinoId) se destino é null: lançar ErroCidadaoNaoEncontrado(cidadaoDestinoId)
tentar: eventBus.publicar({ tipo: 'cidadão.vinculado', origem: 'D-1a', payload: { cidadao_id_origem: cidadaoOrigemId, cidadao_id_destino: cidadaoDestinoId, auth_provider: destino.auth_provider } }) capturar erro: logar erro "Falha ao publicar cidadão.vinculado"A migração das demandas do device para a conta Google acontece no AuthController, entre a verificação do segredo e a publicação do vínculo: DemandaRepository.transferirCidadao() executa o UPDATE em lote. A vinculação exige a prova de posse do dispositivo.
4.6 AnexoService.gerarPresignedUrl() — pseudocódigo
Seção intitulada “4.6 AnexoService.gerarPresignedUrl() — pseudocódigo”função gerarPresignedUrl(dto: PresignedUrlRequestDto) -> { url, expires_in, object_key }:
// content_type é validado no DTO, contra a lista TIPOS_MIME_PERMITIDOS
// Chave do objeto: UUID + nome sanitizado (mantém letras, números, ponto, hífen e underscore) objectKey = `${UUIDv4()}-${sanitizar(dto.filename)}`
// URL pré-assinada de PUT, com PRESIGNED_URL_TTL = 300 segundos urlInterna = minioClient.presignedPutObject(bucket, objectKey, PRESIGNED_URL_TTL)
retornar { url: apresentarUrlPublica(urlInterna), // troca o host pelo MINIO_PUBLIC_ENDPOINT, quando configurado expires_in: PRESIGNED_URL_TTL, object_key: objectKey, // o front-end devolve no payload da demanda }O AnexoService valida as credenciais no construtor (MINIO_ACCESS_KEY e MINIO_SECRET_KEY obrigatórias) e, no onModuleInit, verifica a existência do bucket MINIO_BUCKET (default anexos) e o cria quando ausente. A D-1a não conhece o binário; a checagem real de tamanho ocorre na D-1c.
4.7 TaxonomiaService — carregamento da taxonomia
Seção intitulada “4.7 TaxonomiaService — carregamento da taxonomia”O TaxonomiaService carrega as tabelas core.taxonomia_* via Prisma no OnModuleInit.O banco contém a lista de áreas temáticas com categorias e subcategoriasconforme definido em D-3 - Taxonomia.md. Categorias com ativo = falsenão aparecem no endpoint; áreas sem categoria ativa são omitidas.
Estrutura do dado retornado:
{ "areas": [ { "area_id": "agua_esgoto_drenagem", "nome": "Água, esgoto e drenagem", "icone": "💧", "categorias": [ { "categoria_id": "1.1", "nome": "Abastecimento de água", "icone": "🚱", "subcategorias": [ { "subcategoria_id": "falta_dagua", "nome": "Falta d'água", "icone": "🚱", "descricao_formal": "Falta de água, interrupção no fornecimento, contaminação da rede, pressão insuficiente, ausência de ligação domiciliar" }, ... ] }, ... ] }, ... ]}
O endpoint GET /taxonomia retorna o JSON completo.O front-end usa: area_id + icone + nome para o grid do passo 1,categoria_id + icone + nome para o grid do passo 2,e subcategoria_id + descricao_formal para a tela de confirmação.O TaxonomiaRepository monta o shape em três consultas paralelas (áreas, categorias ativas, subcategorias) e expõe categoriaExiste() usado na validação da captura. Falha no carregamento gera log de erro e a taxonomia permanece vazia; o módulo sobe normalmente.
4.8 Casos de borda
Seção intitulada “4.8 Casos de borda”| Caso | Comportamento |
|---|---|
| Demanda sem texto e sem mídia | HTTP 400. “Título, descrição ou evidência é obrigatório”. |
| Demanda com coordenadas zeradas (0,0) | Bounding box rejeita se (0,0) estiver fora da área de operação. |
| Demanda com coordenadas fora do Brasil | Bounding box rejeita. Parâmetros de referência cobrem o território nacional. |
| Duas demandas idênticas do mesmo cidadão (qualquer intervalo) | Segunda retorna HTTP 409 com o demanda_id da primeira. |
categoria_id inexistente |
HTTP 400 “Categoria inválida”. Categoria existente e inativa é aceita. |
| URL de mídia fora dos hosts permitidos | HTTP 400 “URL de mídia fora dos hosts permitidos”. |
| Device ID não-UUID | HTTP 400 “Header X-Cidadao-Id deve ser um UUID v4”. |
| Segredo do dispositivo divergente | HTTP 403 “Segredo de dispositivo inválido”. |
| INSERT em corrida (demanda ou lugar) | A violação de UNIQUE é capturada: o serviço relê pelo hash de idempotência e responde 409 com o ID existente. Erro sem violação de unicidade sobe como HTTP 500. |
publicarComRetry esgota as tentativas |
Erro logado. HTTP 500. Registro com evento_publicado_em = NULL, recuperado pela varredura do boot. |
| Google token expirado | HTTP 401. |
| Google token de audience diferente | Com GOOGLE_CLIENT_ID configurada, a audience é validada e o token de outro client_id retorna HTTP 401. Sem a variável, a audience não é validada. |
| Google token sem email verificado | HTTP 401. |
| Device ID anônimo vinculado a duas contas Google | A primeira vinculação transfere as demandas do device. A segunda encontra zero demandas para transferir e apenas publica um novo cidadão.vinculado; a D-12 deduplica as validações pelo cidadao_id de destino. |
| Perfil de cidadão anônimo | GET/PATCH /api/cidadaos/me exigem JWT. O device ID não é aceito nessas rotas. |
| Presigned URL para tipo de arquivo não permitido | HTTP 400, na validação do DTO. |
Presigned URL com nome de arquivo contendo path traversal (../../etc/passwd) |
sanitizar() troca por _ tudo que não for letra, número, ponto, hífen ou underscore. O objectKey é UUIDv4 + '-' + nome sanitizado. |
| Varredura do boot encontra lugar sem payload | Log de warning “Lugar órfão sem payload armazenado” e registro ignorado. |
4.9 RateLimitGuard — lógica
Seção intitulada “4.9 RateLimitGuard — lógica”@Injectable()export class RateLimitGuard extends ThrottlerGuard { // O tracker é o IP do cliente. O RateLimitGuard é global (APP_GUARD) e o // X-Cidadao-Id não participa da chave: o cliente pode trocá-lo à vontade. protected async getTracker(req: Request): Promise<string> { if (typeof req.ip === 'string' && req.ip.length > 0) { return req.ip; } if (req.socket?.remoteAddress !== undefined) { return req.socket.remoteAddress; } return 'unknown'; }
// Configuração por rota (aplicada via @Throttle() decorator nos controllers): // POST /demandas: max 5 por minuto por IP // POST /lugares: max 5 por minuto por IP // POST /auth/google: max 10 por minuto por IP // POST /anexos/*: max 10 por minuto por IP // GET /taxonomia: max 60 por minuto por IP // GET /cidadaos/me: max 30 por minuto por IP // PATCH /cidadaos/me: max 10 por minuto por IP // POST /conselheiros: max 5 por minuto por IP // demais rotas da D-1a: fallback do módulo (1 por minuto)}Com TRUST_PROXY=1 e o nginx normalizando a cadeia X-Forwarded-For, o IP do cliente é o real.
4.10 Decisões de design com justificativa
Seção intitulada “4.10 Decisões de design com justificativa”categoria_id do cidadão como metadado validado e publicado.
O fluxo de captura exige que o cidadão selecione categoria e subcategoria (modelo Waze). Esses valores são validados contra core.taxonomia_categorias e publicados no evento 1.2.0. A D-3 categoriza a partir do texto; a seleção é um hint explícito e um metadado de usabilidade. Se o classificador da D-3 discordar consistentemente da seleção do cidadão, a divergência indica ajuste de UI ou de nomenclatura da taxonomia.
Fire-and-forget para eventos de cidadão.
Diferente de demanda.recebida e lugar.recebido, em que a falha de publicação gera HTTP 500, os eventos de cidadão (cadastrado, vinculado, perfil_atualizado) são publicados sem retry e sem propagação do erro. O cadastro já está persistido e funcional. A falha de cidadão.cadastrado atrasa a projeção da D-7, mas não impede o uso do sistema. A D-7 recupera via replay do event_log.
categoria_id e subcategoria_id como VARCHAR, não FK.
A taxonomia vive nas tabelas core.taxonomia_*, fora do schema d1a. As categorias são armazenadas como strings. Se a taxonomia mudar (ex: 1.1 virar 1.1a), registros antigos mantêm o valor original. A validação de que o categoria_id enviado existe é feita em aplicação contra core.taxonomia_categorias. Categoria existente porém inativa é aceita; categoria inexistente retorna HTTP 400.
Retry no caminho de escrita do cidadão, sem retry na identidade.
Demanda, lugar e os três eventos do conselheiro usam publicarComRetry. Os eventos de identidade usam publicar() direto. A justificativa é a mesma do fire-and-forget: a identidade não pode bloquear o uso do app, e o replay do log cobre a projeção.
5. Integração com o Barramento e Outras Colônias
Seção intitulada “5. Integração com o Barramento e Outras Colônias”5.1 Publicação de eventos via EventBusService
Seção intitulada “5.1 Publicação de eventos via EventBusService”A D-1a injeta EventBusService (do módulo @Global() N-0a) e publica eventos diretamente:
@Injectable()export class CapturaService { constructor( private readonly eventBus: EventBusService, private readonly demandaRepo: DemandaRepository, private readonly lugarRepo: IdempotenciaLugarRepository, private readonly taxonomiaRepo: TaxonomiaRepository, ) {}
async criarDemanda(dto: CriarDemandaDto, cidadaoId: string, canal: string) { // ... validação, idempotência, persistência ...
await publicarComRetry(this.eventBus, { tipo: 'demanda.recebida', origem: 'D-1a', versao_schema: '1.2.0', event_id: demandaId, // mesmo UUID da entidade correlacao_id: demandaId, // mesmo UUID para trace payload: { /* ... */ }, }); }}A D-1a não registra consumer offset próprio porque não consome eventos. O EventBusService é usado exclusivamente para publicar.
5.2 Relação com outras colônias — fluxo de eventos
Seção intitulada “5.2 Relação com outras colônias — fluxo de eventos”POST /demandas → demanda.recebida → D-1b (Normalização), D-1c (Anexos)POST /lugares → lugar.recebido → L-1 (Cadastro de Lugares)
Auth Google → cidadão.cadastrado → D-7 (Transparência) → cidadão.vinculado → D-7 (Transparência), D-12 (duplicidade)PATCH /cidadaos/me → cidadão.perfil_atualizado → colônias com projeção de perfil
POST /conselheiros → conselheiro.cadastrado → D-6aPOST .../recusar → conselheiro.atribuicao_recusada → D-6aPOST /conselheiros/atualizacoes → conselheiro.atualização_registrada → D-6bA D-1a não invoca essas colônias diretamente. Apenas publica no barramento. A cadeia é orquestrada pelo consumo dos eventos.
5.3 Chamadas síncronas via BFF — proxy para outras colônias
Seção intitulada “5.3 Chamadas síncronas via BFF — proxy para outras colônias”No MVP, a D-1a faz zero chamadas HTTP para outras colônias. Todos os dados necessários ao front-end (taxonomia) vêm do schema core, e os dados de perfil e timeline são consumidos pelo front-end diretamente da D-7 (Transparência).
As operações de conselheiro do BFF seguem o mesmo padrão de evento das demais. Cadastro, recusa e atualização publicam no barramento com publicarComRetry e correlacao_id próprio; a colônia dona do dado processa de forma assíncrona. A exceção única do AGENTS.md da API (BFF como proxy de operações síncronas iniciadas pelo front-end, incluindo cadastro e recusa de conselheiro) cobre o caso de particionamento futuro do monolito e não é exercitada hoje.
Na Fase 2, quando o monolito se partir em microsserviços, o BFF da D-1a pode passar a consultar a C-1 (Cadastro de Cidadãos) para dados de perfil e a D-3 (Categorização) para taxonomia dinâmica. Essas chamadas são HTTP entre serviços, com o BFF atuando como proxy de entrada. Ele recebe a requisição do front-end, consulta o serviço dono do dado, e retorna a resposta. O BFF não processa, não transforma e não decide sobre o dado. Apenas roteia.
5.4 Dependências de projeções de leitura
Seção intitulada “5.4 Dependências de projeções de leitura”A D-1a não consome projeções de leitura de outras colônias. A taxonomia é lida das tabelas de referência core.taxonomia_*. Os dados de cidadão residem no próprio schema d1a.
5.5 Relação com a D-1c (Gestão de Anexos)
Seção intitulada “5.5 Relação com a D-1c (Gestão de Anexos)”A D-1a e a D-1c têm responsabilidades complementares sem acoplamento direto:
- D-1a: gera presigned URL (
POST /anexos/presigned-url) e valida a URL contra a allowlist de hosts. Não gerencia storage. - Front-end: faz upload diretamente para o MinIO usando a presigned URL.
- D-1a: recebe a URL final e o
object_keyno payload dePOST /demandas(campomidia_urls). - D-1c: consome
demanda.recebida, extrai as URLs, valida os arquivos (magic bytes, hash SHA-256), deduplica e publicaanexo.processadocom URL de acesso estável.
A D-1a não chama a D-1c e a D-1c não chama a D-1a. A comunicação é via barramento (demanda.recebida → D-1c) e via storage compartilhado (MinIO). O contrato é o campo midia_urls no payload da demanda.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”| Rota | Limite | Janela | Chave | Biblioteca |
|---|---|---|---|---|
POST /demandas |
5 | 1 minuto | IP | @nestjs/throttler in-memory |
POST /lugares |
5 | 1 minuto | IP | @nestjs/throttler in-memory |
POST /auth/google |
10 | 1 minuto | IP | @nestjs/throttler in-memory |
POST /anexos/presigned-url |
10 | 1 minuto | IP | @nestjs/throttler in-memory |
GET /taxonomia |
60 | 1 minuto | IP | @nestjs/throttler in-memory |
GET /cidadaos/me |
30 | 1 minuto | IP | @nestjs/throttler in-memory |
PATCH /cidadaos/me |
10 | 1 minuto | IP | @nestjs/throttler in-memory |
POST /conselheiros |
5 | 1 minuto | IP | @nestjs/throttler in-memory |
POST /conselheiros/atribuicoes/:demanda_id/recusar |
5 | 1 minuto | IP | @nestjs/throttler in-memory |
POST /conselheiros/atualizacoes |
5 | 1 minuto | IP | @nestjs/throttler in-memory |
POST /assistencia/texto |
10 | 1 minuto | IP | @nestjs/throttler in-memory |
Os limites são parâmetros iniciais de referência, calibráveis com dados reais. O @nestjs/throttler armazena contadores em memória (Map). No MVP monolito com único processo, isso é suficiente. A limitação: reiniciar o processo reseta todos os contadores. Para o MVP de bairro com volume baixo, aceitável. Na Fase 2, migrar para @nestjs/throttler com Redis store.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
| Body JSON do POST | 100 KB (default do Express) | Suficiente para texto + array de URLs de mídia. Imagens e áudio trafegam via presigned URL. O payload contém apenas strings. |
titulo |
200 caracteres | Suficiente para uma frase curta descritiva. |
descricao da demanda |
5000 caracteres | Texto livre do cidadão. Equivalente a ~1 página de texto. |
descricao do lugar (DTO) |
2000 caracteres | Mesmo limite do schema de lugar.recebido no Registry. |
texto_bruto (sintetizado) |
5000 caracteres | Limite do schema demanda.recebida no Registry. |
| Texto analisado pela assistência | 5000 caracteres | Mesmo limite da descrição da demanda. A rota é stateless e o texto não é persistido. |
midia_urls (array) |
10 URLs | Suficiente para múltiplas fotos de um problema. |
categoria_id |
20 caracteres | Formato 1.1, 2.3 da taxonomia. |
subcategoria_id |
50 caracteres | Identificador da subcategoria. |
consentimentos |
3 finalidades | Metadados de consentimento por envio. |
nome (lugar) |
200 caracteres | Nome de estabelecimento ou equipamento público. |
subtipo (lugar) |
100 caracteres | Classificação secundária do lugar. |
horario_funcionamento |
200 caracteres | Texto livre curto. |
texto_bruto/texto_estruturado (conselheiro) |
10000 caracteres | Atualização do acompanhamento. |
| Presigned URL expiration | 300 segundos | PRESIGNED_URL_TTL. Após expirar, solicitar nova URL. |
| Arquivo via presigned URL (imagem) | 10 MB | Limite de LIMITES_TAMANHO da D-1c, que valida o binário. |
| Arquivo via presigned URL (áudio) | 5 MB | Limite de LIMITES_TAMANHO da D-1c, que valida o binário. |
| Segredo de dispositivo | mínimo de 16 caracteres | TAMANHO_MINIMO_SEGREDO; o valor em si nunca é persistido. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
cidadaos_pkey (id) |
buscarPorId() — toda requisição identificada |
cidadaos_google_sub_key (UNIQUE) |
buscarPorGoogleSub() — login Google |
cidadaos_auth_provider_idx |
Análise: quantos anônimos vs. Google |
cidadaos_google_sub_idx |
Consulta por google_sub |
demandas_recebidas_pkey (id) |
Acesso direto por demanda_id |
demandas_recebidas_idempotencia_hash_key (UNIQUE) |
Verificação de idempotência: WHERE idempotencia_hash = ? — todo POST |
demandas_recebidas_cidadao_id_idx |
Transferência de titularidade e “Minhas demandas” (futuro): WHERE cidadao_id = ? |
demandas_recebidas_status_idx |
Varredura de órfãos e acompanhamento por status |
idempotencia_lugares_pkey (idempotencia_hash) |
Verificação de idempotência: WHERE idempotencia_hash = ? |
idempotencia_lugares_criado_em_idx |
Ordenação da varredura de órfãos e limpeza futura de hashes |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”O padrão de acesso dominante é INSERT (criação de demanda/lugar). A D-1a é write-heavy por natureza. SELECTs são raros e pontuais:
findOrCreateAnonymous(): 1 consulta por POST de demanda, lugar e por login com device; cria o registro quando ausente.buscarPorIdempotenciaHash()ebuscarPorHash(): 1 consulta por POST. O índice UNIQUE cobre. Esperado < 10 queries/segundo no MVP de bairro.buscarPorGoogleSub(): apenas no fluxo de login Google. Esperado < 1 query/minuto no MVP.categoriaExiste(): no máximo 1 consulta por POST de demanda com categoria.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”Sem cache na D-1a. O CidadaoService consulta o banco a cada operação identificada; o volume do MVP não justifica cache e a identidade precisa refletir o vínculo mais recente. A taxonomia é carregada uma vez no boot e servida da memória. Demandas e lugares não têm leitura repetida no BFF: a leitura é feita por outras colônias via eventos ou pela D-7 via projeções.
Redis não se justifica no MVP para a D-1a. O volume de leitura é baixo.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| Cenário | Demandas/dia | Lugares/dia | Cidadãos cadastrados | Tamanho estimado do banco (ano) |
|---|---|---|---|---|
| PoC (1 bairro, 10 entusiastas) | ~50 | ~20 | ~10 anônimos | < 10 MB |
| MVP (1 município, centenas de usuários) | ~500 | ~100 | ~500 (90% anônimos) | < 100 MB |
| Fase 2 (regional) | ~50.000 | ~10.000 | ~50.000 | < 10 GB |
O crescimento é linear com a base de usuários. A D-1a escala horizontalmente sem conflito. O estado próprio é um banco PostgreSQL que escala com réplicas de leitura (se necessário) e a idempotência é garantida por constraint UNIQUE.
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”Teste unitário do CapturaService:
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ CapturaService, { provide: EventBusService, useValue: mockEventBus }, { provide: DemandaRepository, useValue: mockDemandaRepo }, { provide: IdempotenciaLugarRepository, useValue: mockLugarRepo }, { provide: TaxonomiaRepository, useValue: mockTaxonomiaRepo }, ], }).compile();
service = module.get(CapturaService);
// Mocks configurados para o caminho feliz mockTaxonomiaRepo.categoriaExiste.mockResolvedValue(true); mockDemandaRepo.buscarPorIdempotenciaHash.mockResolvedValue(null); mockDemandaRepo.inserir.mockImplementation((dados) => Promise.resolve({ ...dados, criado_em: new Date().toISOString() })); mockDemandaRepo.atualizarEventoPublicadoEm.mockResolvedValue(undefined); mockEventBus.publicar.mockResolvedValue({ sequence_number: 1, event_id: 'test-uuid', tipo: 'demanda.recebida' });});Teste unitário do CidadaoService:
Mock de CidadaoRepository + EventBusService. Os cenários cobrem Device ID inválido, criação anônima, segredo divergente, vinculação de segredo, corrida de unicidade e login Google. O CidadaoRepository é isolado por interface. Na Fase 2, trocar a implementação de SQL local para HTTP client não altera os testes.
Testes unitários dos demais serviços:
AuthService, TokenService, TaxonomiaService, AnexoService e ConselheiroService têm specs próprios. O controller de conselheiro verifica os metadados de throttle e o mapeamento de erros. O RateLimitGuard tem spec do tracker por IP.
7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Happy path:
| # | Cenário | Verificação |
|---|---|---|
| T1 | POST /demandas com dados válidos (título + coordenadas) |
HTTP 201. Body contém demanda_id. publicarComRetry chamado com tipo demanda.recebida e versao_schema: '1.2.0'. Registro em d1a.demandas_recebidas com status pendente_normalizacao. |
| T2 | POST /lugares com dados válidos |
HTTP 201. Body contém rastreamento_id. Evento lugar.recebido publicado. Payload não contém lugar_id. |
| T3 | POST /auth/google com token válido, cidadão novo |
HTTP 200. Body: { cidadao_id, nome, email, auth_provider, token }. Registro em d1a.cidadaos com auth_provider='google'. Evento cidadão.cadastrado publicado. |
| T4 | POST /auth/google com token válido, cidadão existente |
HTTP 200. Mesmo cidadao_id retornado. Metadados atualizados. Nenhum evento novo publicado. |
| T5 | POST /auth/google + X-Cidadao-Id e segredo válido |
Demandas do device migradas para a conta Google. Evento cidadão.vinculado publicado. |
| T6 | PATCH /cidadaos/me com JWT, endereço e UC |
HTTP 200. Evento cidadão.perfil_atualizado publicado com campos_alterados. |
| T7 | GET /taxonomia |
HTTP 200. Retorna JSON com áreas, categorias e subcategorias carregadas de core.taxonomia_*. |
| T8 | POST /anexos/presigned-url com content_type válido |
HTTP 200. Body: { url, expires_in: 300, object_key }. URL é string não vazia. |
| T9 | POST /conselheiros com JWT Google e capacitacao_concluida: true |
HTTP 201. Evento conselheiro.cadastrado 1.1.0 publicado. |
| T10 | Varredura de órfãos no boot com demanda sem evento_publicado_em |
Evento republicado e campo preenchido. |
Falhas e bordas:
| # | Cenário | Verificação |
|---|---|---|
| T11 | POST /demandas sem X-Cidadao-Id |
HTTP 400. |
| T12 | POST /demandas sem coordenadas |
HTTP 400, na validação do DTO. |
| T13 | POST /demandas sem título, descrição nem mídia |
HTTP 400. “Título, descrição ou evidência é obrigatório”. |
| T14 | POST /demandas com coordenadas fora do bounding box |
HTTP 400. “Coordenadas fora da área de operação”. |
| T15 | POST /demandas com fix acima de 10 km |
HTTP 400. “Localização fora do raio de 10 km da posição do dispositivo”. |
| T16 | POST /demandas com categoria_id inexistente |
HTTP 400. “Categoria inválida”. |
| T17 | POST /demandas com URL de mídia fora da allowlist |
HTTP 400. “URL de mídia fora dos hosts permitidos”. |
| T18 | POST /demandas duplicada (mesmo conteúdo, mesmo cidadão, em qualquer momento) |
HTTP 409 com { statusCode, demanda_id, duplicata: true, message }. Evento NÃO publicado. |
| T19 | POST /demandas excedendo rate limit |
HTTP 429. Header Retry-After presente. |
| T20 | POST /demandas com falha de publicação após os retries |
HTTP 500. Registro existe no banco com evento_publicado_em = NULL. |
| T21 | POST /demandas com segredo de dispositivo divergente |
HTTP 403. “Segredo de dispositivo inválido”. |
| T22 | POST /auth/google com token expirado ou sem email verificado |
HTTP 401. |
| T23 | POST /auth/google com token de outro client_id e GOOGLE_CLIENT_ID configurada |
HTTP 401. |
| T24 | POST /auth/google com device e segredo inválido |
HTTP 403, antes da transferência de demandas. |
| T25 | POST /anexos/presigned-url com content_type não permitido |
HTTP 400, na validação do DTO. |
| T26 | PATCH /cidadaos/me sem JWT |
HTTP 401. O device ID não é aceito. |
| T27 | GET /taxonomia com falha no carregamento no boot |
Log de erro; endpoint responde []; o módulo sobe. |
| T28 | POST /demandas com titulo de 300 caracteres |
HTTP 400. Validado por class-validator. |
| T29 | POST /demandas com array midia_urls de 15 itens |
HTTP 400. |
| T30 | POST /conselheiros/atualizacoes com origem_estruturacao='ia_assistida' sem sugestao_ia |
HTTP 400. |
Teste de integração (com banco PostgreSQL de teste):
| # | Cenário | Verificação |
|---|---|---|
| T31 | Ciclo completo: POST demanda → INSERT no banco → evento no event_log | d1a.demandas_recebidas tem 1 linha com evento_publicado_em não nulo. core.event_log tem 1 linha com tipo = 'demanda.recebida'. |
| T32 | Idempotência com PostgreSQL real: dois POSTs idênticos | Primeira: 201. Segunda: 409. count(*) em d1a.demandas_recebidas = 1. |
| T33 | Vinculação de dispositivo: demandas transferidas | Antes: demandas com cidadao_id = anonId. Após o login com segredo válido: demandas com cidadao_id = googleId. count(*) total inalterado. |
| T34 | Criação de cidadão Google com google_sub já existente |
Segunda chamada retorna mesmo cidadao_id. Sem INSERT adicional. Sem evento duplicado. |
| T35 | Varredura de órfãos: registro com evento_publicado_em = NULL |
Evento republicado no boot e campo preenchido. Lugar sem payload é ignorado com warning. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”-- Cidadão anônimoINSERT INTO d1a.cidadaos (id, auth_provider)VALUES ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'anonymous');
-- Cidadão GoogleINSERT INTO d1a.cidadaos (id, nome, email, auth_provider, google_sub)VALUES ( 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'Maria Silva', 'maria@gmail.com', 'google', 'google-sub-12345');
-- Demanda de exemploINSERT INTO d1a.demandas_recebidas (id, cidadao_id, texto_bruto, localizacao_lat, localizacao_lng, categoria_id, subcategoria_id, titulo, canal, status, idempotencia_hash, evento_publicado_em)VALUES ( 'c3d4e5f6-a7b8-9012-cdef-123456789012', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Falta d''água na Rua das Flores. Sem abastecimento desde ontem à noite.', -23.55052, -46.63331, '1.1', 'falta_dagua', 'Falta d''água na rua', 'app', 'pendente_normalizacao', 'abc123def456', -- substituir por hash real nos testes '2026-06-15T10:30:00Z');
-- Idempotência de lugar (hash + rastreamento_id + payload)INSERT INTO d1a.idempotencia_lugares (idempotencia_hash, rastreamento_id, payload, evento_publicado_em)VALUES ( 'ghi789jkl012', 'd4e5f6a7-b8c9-0123-defa-123456789abc', '{"tipo_lugar":"organizacao","posicao":{"lat":-23.55052,"lng":-46.63331}}', '2026-06-15T10:35:00Z');Para testes de idempotência, usar idempotencia_hash gerado deterministicamente a partir dos mesmos dados do seed. Para testes de bounding box, configurar LAT_MIN/LAT_MAX/LNG_MIN/LNG_MAX nas variáveis de ambiente de teste cobrindo as coordenadas do seed.
8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| Funcionalidade | Status |
|---|---|
POST /demandas com validação, persistência e publicação de demanda.recebida 1.2.0 |
MVP obrigatório |
POST /lugares com validação, idempotência e publicação de lugar.recebido |
MVP obrigatório |
POST /auth/google com verificação de token e vinculação de device anônimo com prova de posse |
MVP obrigatório |
POST /anexos/presigned-url para upload direto ao MinIO |
MVP obrigatório |
GET /taxonomia servindo a taxonomia das tabelas core.taxonomia_* |
MVP obrigatório |
GET /cidadaos/me e PATCH /cidadaos/me para perfil, com JWT |
MVP obrigatório |
Cadastro de cidadãos (device anônimo + Google) no schema d1a |
MVP obrigatório |
Segredo de dispositivo (X-Device-Segredo) com hash SHA-256 e prova de posse no vínculo |
MVP obrigatório |
| POSTs do conselheiro (cadastro, recusa e atualizações) publicando eventos | MVP obrigatório |
Varredura de saídas órfãs no boot (evento_publicado_em IS NULL) |
MVP obrigatório |
| Rate limiting por IP | MVP obrigatório |
| Validação de bounding box e do raio de captura de 10 km | MVP obrigatório |
Validação de categoria contra core.taxonomia_categorias e de URLs de mídia contra a allowlist de hosts |
MVP obrigatório |
Idempotência por idempotencia_hash (SHA-256 de conteúdo determinístico) |
MVP obrigatório |
Logs estruturados com demanda_id/rastreamento_id e cidadao_id |
MVP obrigatório |
| Interface de repositório de cidadãos isolada para extração futura (C-1) | MVP obrigatório |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| Simplificação | Justificativa | Quando remover |
|---|---|---|
@nestjs/throttler in-memory (sem Redis) |
Monolito de processo único. Volume baixo. Reiniciar reseta contadores. Aceitável para MVP de bairro. | Migrar para Redis store na Fase 2 quando houver múltiplas instâncias. |
Taxonomia lida das tabelas core.taxonomia_*, não via API da D-3 |
A D-3 não tem controller REST no MVP. A taxonomia é estática na Fase 1. As tabelas são a fonte, populadas por migration e atualizadas manualmente via SQL. | Substituir TaxonomiaService por chamada HTTP à D-3 quando a taxonomia se tornar dinâmica. |
categoria_id e subcategoria_id publicados como campos do evento 1.1.0, sem concatenar ao texto |
O classificador da D-3 trabalha a partir do texto. A seleção do cidadão viaja em campos próprios. | Avaliar pesos do hint quando houver dados de acerto do classificador. |
Sem endpoint de republicação de evento (POST /demandas/:id/republicar) |
A varredura do boot cobre a janela de falha publish-após-INSERT no monolito. | Adicionar endpoint de republicação manual na Fase 2, se a operação precisar. |
idempotencia_hash sem componente temporal |
Retries de rede reenviam o mesmo payload e recebem 409 com o ID existente. Idempotência permanente por conteúdo. | Fase 2: chave de idempotência enviada pelo front-end, se necessário. |
Validação de categoria_id apenas por existência, com categoria inativa aceita |
O front-end envia categorias da taxonomia carregada. Rejeitar inativa quebraria clientes com cache de 24h desatualizado depois de uma desativação manual. | Reavaliar a política de inatividade quando a taxonomia for servida dinamicamente pela D-3 e o front-end fizer cache stale. |
| Bounding box via quatro constantes (lat min/max, lng min/max) | Suficiente para rejeitar coordenadas obviamente fora do Brasil. O georreferenciamento preciso é responsabilidade da D-2. | Substituir por point-in-polygon com PostGIS se a D-1a precisar resolver UC na entrada (Fase 2). |
| Sem cache de cidadão | A consulta por id é indexada e o volume do MVP é baixo. Evita servir identidade desatualizada após vinculação. | Reavaliar quando a C-1 for extraída e a latência de rede entre serviços for relevante. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Extração da tabela
cidadaospara a C-1 (Cadastro de Cidadãos) como microsserviço independente - BFF da D-1a passa a consumir C-1 via HTTP para dados de perfil
- Autenticação gov.br como terceiro provider de identidade (junto com anônimo e Google)
- Idempotency key via header
Idempotency-Key(substitui o hash de conteúdo) - Rate limiting com Redis store (suporte a múltiplas instâncias)
- Endpoint de republicação de eventos (
POST /demandas/:id/republicar) e job agendado de reconciliação - Taxonomia servida dinamicamente pela D-3 (BFF atua como proxy HTTP)
- Validação geoespacial precisa (point-in-polygon com PostGIS)
- Extensão do schema
demanda.recebidacomcategoria_id_sugeridaesubcategoria_id_sugerida - Métricas Prometheus de negócio:
demandas_created_total,lugares_created_total,auth_logins_total
8.4 Verificação de conflitos com outras colônias
Seção intitulada “8.4 Verificação de conflitos com outras colônias”Conflito potencial: categoria_id e subcategoria_id na D-1a e no evento.
A D-3 (Categorização) categoriza a partir do texto. A seleção do cidadão é publicada no evento 1.1.0 como hint e metadado, não como decisão. A D-3 é a fonte canônica da categorização. Sem conflito com o pipeline.
Conflito potencial: tabela cidadaos no schema d1a que outras colônias precisam consultar.
No MVP, apenas a D-1a escreve e lê d1a.cidadaos. Outras colônias que precisam de dados de cidadão (ex: D-6a para sorteio) recebem via projeção da D-7 ou pelo cidadao_id que já carregam nos eventos. Nenhuma colônia acessa d1a.cidadaos diretamente. O repositório está isolado e pronto para extração. Sem conflito.
Conflito potencial: endpoint de taxonomia na D-1a vs. responsabilidade da D-3.
A taxonomia é definida na D-3 (Categorização), mas o front-end precisa dela para o grid de seleção. No MVP, a D-3 não tem controller REST e a D-1a serve as tabelas core.taxonomia_*, dados de referência do schema do núcleo. As tabelas são populadas por migration a partir da fixture src/shared/taxonomia/taxonomia-seed.json e atualizadas manualmente via SQL. A D-3 lê as mesmas tabelas no boot para montar o mapa de categorias do classificador. Não há comunicação entre as colônias. Na Fase 2, a D-3 ganha um endpoint REST e a D-1a passa a consumi-lo como proxy. Sem conflito no MVP.
Conflito potencial: MinIO bucket compartilhado entre D-1a e D-1c. A D-1a gera presigned URLs e a D-1c processa os arquivos após upload. Ambas acessam o mesmo bucket MinIO. Isso não viola a regra de isolamento porque MinIO é infraestrutura compartilhada (como o PostgreSQL), não estado próprio de colônia. Cada colônia lê/escreve objetos com prefixos previsíveis e sem conflito de escrita (a D-1a gera a chave, o front-end escreve, a D-1c lê e valida). Sem conflito.
Referências
Seção intitulada “Referências”- Índice da colônia: D-1a - Captura.md
- Camada irmã (front-end): D-1a - Front-end.md
- Schemas de eventos: N-0b - Registry.md, seções 3.4.1 (
demanda.recebida), 3.4.19 (lugar.recebido), 3.4.28 (cidadão.cadastrado), 3.4.29 (cidadão.perfil_atualizado), 3.4.30 (cidadão.vinculado), 3.4.10 (conselheiro.cadastrado), 3.4.17 (conselheiro.atribuicao_recusada) e 3.4.18 (conselheiro.atualização_registrada) - Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônias consumidoras dos eventos da D-1a: D-1b - Normalização.md, D-1c - Gestão de Anexos.md, L-1 - Cadastro de Lugares.md
- Cadastro de cidadãos (Fase 2): C-1 - Cadastro de Cidadãos.md
- Taxonomia de categorias: D-3 - Taxonomia.md
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
- Contexto de gestão: contexto_IA.md, seção 7 (Gestão)
- Contexto de infraestrutura: contexto_IA.md, seção 10 (Infraestrutura cívica digital)
- Contexto do Formigueiro: contexto_IA.md, seção 22 (Arquitetura técnica — O Formigueiro)
Documento de especificação técnica de implementação. Aprovado e integrado.