Pular para o conteúdo

D-1a — BFF (Backend for Frontend)

Parte da D-1a — Captura / D-1 — Ingestão de Demanda


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.


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.

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 IP
@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
}
}
  • 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 EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import. A D-1a publica eventos, não os consome.
  • O módulo importa ThrottlerModule.forRoot() com configuração de fallback. O RateLimitGuard é registrado como APP_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 D1aModule implementa OnModuleInit e chama CapturaService.iniciar(), que executa a varredura de órfãos no boot.
  • O TaxonomiaService carrega as tabelas core.taxonomia_* (áreas, categorias com filtro ativo, subcategorias) via Prisma no OnModuleInit e monta o shape do grid. Não existe arquivo config/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.

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.

// CapturaService
criarDemanda(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
// CidadaoService
buscarPorId(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>;
// AuthService
verificarGoogleToken(idToken: string): Promise<GoogleTokenPayload>;
// TokenService
assinar(payload: PayloadToken): string;
verificar(token: string): TokenVerificadoCidadao;
// TaxonomiaService
getAreas(): AreaTematica[];
getCategorias(areaId: string): CategoriaGrid[];
// AnexoService
gerarPresignedUrl(dto: PresignedUrlRequestDto): Promise<{ url: string; expires_in: number; object_key: string }>;
// ConselheiroService
cadastrar(dto: CadastrarConselheiroDto, cidadaoId: string): Promise<CadastroConselheiroResponse>;
recusarAtribuicao(demandaId: string, dto: RecusarAtribuicaoDto, cidadaoId: string): Promise<RecusaAtribuicaoResponse>;
registrarAtualizacao(dto: RegistrarAtualizacaoConselheiroDto, cidadaoId: string): Promise<AtualizacaoRegistradaResponse>;
// AssistenciaService
revisarTexto(texto: string): Promise<RespostaAssistencia>;
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.

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.


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.

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");
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.

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.

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.

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");
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.

As migrations que criam e evoluem o schema d1a:

  1. 20260809101544_create_d1a_schema — Cria o schema d1a e as tabelas cidadaos, demandas_recebidas e idempotencia_lugares, com índices e a unique constraint de idempotência.
  2. 20260814191025_add_payload_idempotencia_lugares — Adiciona a coluna payload em idempotencia_lugares.
  3. 20260910153000_d1a_cidadao_device_secret_hash — Adiciona a coluna device_secret_hash em cidadaos.
  4. 0008_d1a_assistencia — Adiciona a coluna assistencia em demandas_recebidas.

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.

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.


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).

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.

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.

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.

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.

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.

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.

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.

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 com publicarComRetry. O payload reconstruído não inclui termos_versao, consentimentos, categoria_id nem subcategoria_id, que não são persistidos de forma separada.
  • Lugares: busca até 100 registros e republica o payload armazenado. 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.
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.

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.

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.

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.titulo
se 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.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 }
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 cidadao

4.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 cidadao

4.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 subcategorias
conforme definido em D-3 - Taxonomia.md. Categorias com ativo = false
nã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.

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.
@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.

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”

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-6a
POST .../recusar → conselheiro.atribuicao_recusada → D-6a
POST /conselheiros/atualizacoes → conselheiro.atualização_registrada → D-6b

A 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.

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.

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_key no payload de POST /demandas (campo midia_urls).
  • D-1c: consome demanda.recebida, extrai as URLs, valida os arquivos (magic bytes, hash SHA-256), deduplica e publica anexo.processado com 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.


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.

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.
Í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

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() e buscarPorHash(): 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.

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.

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.


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.

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.
-- Cidadão anônimo
INSERT INTO d1a.cidadaos (id, auth_provider)
VALUES ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'anonymous');
-- Cidadão Google
INSERT 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 exemplo
INSERT 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.


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
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.
  • Extração da tabela cidadaos para 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.recebida com categoria_id_sugerida e subcategoria_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.



Documento de especificação técnica de implementação. Aprovado e integrado.