Integrar um sistema de atendimento e despacho
Este guia é para quem mantém um — um sistema de atendimento e
— e precisa que ele converse com o :declarar quem está de serviço e em qual , registrar e atualizar
, empenhar viatura e receber o que acontece depois, inclusive a.Todo trecho de código aqui vem do pacote @geocontrol/exemplo-integrador-despacho, que roda contra o Geohub de verdade numa suíte de testes. Nada é colado: se o exemplo quebrar, o gate reprova antes de este guia envelhecer.
Visão geral: o Geohub é a fonte da verdade
A regra que explica todas as outras está no RDT-025: o Geohub é a fonte da verdade do que acontece em campo, e funciona sem o . O policial tem o aplicativo dele, a central tem as telas dela, e a ocorrência atravessa o ciclo inteiro sem que nenhum sistema externo participe.
O que a integração faz é duas coisas, e só duas:
- o CAD age no Geohub, em nome dele próprio. Todo comando leva a
integrador, e ela vai emorigem.autoriade cada evento que o comando causar. Quem olhar a ocorrência depois sabe que foi o CAD — não um policial, não "o sistema"; - o Geohub devolve o que aconteceu, em , pelo . O que nasceu no celular do policial chega ao CAD no mesmo formato do que nasceu no CAD.
Três consequências práticas disso, e é melhor conhecê-las antes de escrever a primeira linha:
- o Geohub não espera o CAD. Se o callback estiver fora do ar, a ocorrência continua andando; o evento fica no outbox e é reenviado;
- toda escrita passa pelos plugins. Gravar direto nas coleções do não gera retorno nenhum — e a permissão do integrador não opermite;
- o CAD não dita estado. Ele pede atos (
despachar,registrarChegada); o status é consequência, e quem decide se o ato cabe é o domínio.
SDK ou REST: como escolher
As duas formas de integrar saem do mesmo manifesto do plugin (RDT-027): as rotas em runtime, a spec OpenAPI e o cliente TypeScript. Elas não podem divergir, porque nenhuma delas é escrita à mão.
| SDK TypeScript | REST + OpenAPI | |
|---|---|---|
| para quem | integrador em Node/TypeScript | qualquer outra linguagem |
| o que você chama | IOcorrenciaHub, IJornadaHub | POST /plugins/ocorrencias, … |
| caminhos, status, cabeçalhos | o SDK cuida | você escreve (ou gera) |
| ligada por padrão | você manda o cabeçalho |
O exemplo deste guia declara portas no vocabulário do integrador e as implementa duas vezes — uma pelo SDK, outra por fetch. É por isso que o mesmo roteiro roda nos dois transportes sem um caso editado, e é um desenho que vale copiar: ele isola o seu código do transporte que você escolher.
/** Os verbos de `jornada` que o papel `Integração - Sistema de despacho` usa. */
export interface PortaDeJornada {
assumirComando(dados: {
guarnicaoId: GuarnicaoId;
jornadaId: JornadaId;
}): Promise<Desfecho<void, FalhaDeJornada>>;
constituirGuarnicao(
dados: DadosDaGuarnicao,
): Promise<Desfecho<GuarnicaoId, FalhaDeJornada>>;
declararProntidao(
guarnicaoId: GuarnicaoId,
): Promise<Desfecho<void, FalhaDeJornada>>;
/** Leitura: `undefined` quando a viatura não tem guarnição `disponivel`. */
guarnicaoDisponivelNaViatura(
viatura: ViaturaId,
): Promise<GuarnicaoElegivel | undefined>;
iniciarJornada(dados: {
id: JornadaId;
posto: PostoOcupado;
}): Promise<Desfecho<void, FalhaDeJornada>>;
registrarApresentacao(
usuario: UsuarioId,
): Promise<Desfecho<JornadaId, FalhaDeJornada>>;
}
/** Os verbos de `ocorrencia` que o mesmo papel usa. */
export interface PortaDeOcorrencia {
atualizarOcorrencia(dados: {
alteracoes: AlteracoesDeOcorrencia;
id: OcorrenciaId;
}): Promise<Desfecho<void, FalhaDeOcorrencia>>;
despachar(dados: {
instrucoes?: string;
ocorrenciaId: OcorrenciaId;
recurso: RecursoGuarnicao;
}): Promise<Desfecho<DespachoId, FalhaDeOcorrencia>>;
registrarOcorrencia(
dados: DadosDeRegistroDeOcorrencia,
): Promise<Desfecho<OcorrenciaId, FalhaDeOcorrencia>>;
}// #endregion desfecho
// #endregion portas
export {};Prefira o SDK se puder. Ele é o próprio contrato do plugin do outro lado da rede — a suíte de contrato do IOcorrenciaHub roda contra ele sem um caso editado, o que torna "o cliente remoto cumpre o mesmo contrato" uma afirmação verificada e não um desenho.
import {
criarClienteJornada,
matricula,
modalidadeDeServicoId,
usuarioId,
} from '@geocontrol/geohub-sdk/jornada';
import { CONEXAO } from './conexao.js';
const jornada = criarClienteJornada(CONEXAO);
// Na sessão de integrador, o usuário dos verbos é a matrícula do policial.
const policial = usuarioId(matricula('123456'));
// Dois verbos: comparecer à unidade e entrar em serviço no posto ocupado.
const apresentacao = await jornada.registrarApresentacao(policial);
if (!apresentacao.success) throw new Error(apresentacao.error.codigo);
export const jornadaId = apresentacao.data;
export const inicio = await jornada.iniciarJornada({
id: jornadaId,
posto: { modalidade: modalidadeDeServicoId('radio_patrulhamento') },
});import {
criarClienteJornada,
matricula,
modalidadeDeServicoId,
usuarioId,
} from '@geocontrol/geohub-sdk/jornada';
import { CONEXAO } from './conexao.js';
const jornada = criarClienteJornada(CONEXAO);
// Na sessão de integrador, o usuário dos verbos é a matrícula do policial.
const policial = usuarioId(matricula('123456'));
// Dois verbos: comparecer à unidade e entrar em serviço no posto ocupado.
const apresentacao = await jornada.registrarApresentacao(policial);
if (!apresentacao.success) throw new Error(apresentacao.error.codigo);
export const jornadaId = apresentacao.data;
export const inicio = await jornada.iniciarJornada({
id: jornadaId,
posto: { modalidade: modalidadeDeServicoId('radio_patrulhamento') },
});O SDK é o pacote @geocontrol/geohub-sdk, uma entrada por plugin (/jornada, /ocorrencia): recebe a raiz do Geohub e o token, e compõe o resto. A página pelo SDK tem os passos inteiros.
Onde está a OpenAPI, e como gerar cliente dela
A spec é publicada em /openapi.json do próprio site de documentação (e renderizada em API). Ela é OpenAPI 3.1, e as operações dos plugins trazem a tag Ocorrência — plugin exposto e <Termo id="t-113">Jornada</Termo> — <Termo id="plugin-exposto">plugin exposto</Termo>.
# Java, Python, C#, Go… — o gerador da sua linguagem
openapi-generator-cli generate -i https://<seu-geohub-docs>/openapi.json \
-g java -o ./cliente-geohubDuas coisas para ler antes de gerar:
info.x-contratos-de-plugintraz a versão e o hash de cada contrato de plugin. Aversiondo documento é do documento; a de um plugin é dele, e é por esse campo que você descobre que o contrato mudou sem comparar listas de paths;serverstem uma URL de exemplo. Troque pela do seu ambiente.
Autenticação e o papel Integração - Sistema de despacho
O CAD autentica como usuário de serviço — não com o token de uma pessoa. É o que permite ao Geohub dizer, em cada evento, que quem agiu foi o sistema externo.
/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(
credenciais: CredenciaisDoIntegrador,
): Promise<SessaoDoIntegrador> {
const buscar = credenciais.buscar ?? globalThis.fetch;
const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
body: JSON.stringify({
email: credenciais.email,
password: credenciais.senha,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
const corpo = (await resposta.json()) as RespostaDeLogin;
const token = corpo.data?.access_token;
if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
return new SessaoDoIntegrador(
credenciais.geohub,
token,
credenciais.integradorId,
credenciais.buscar,
credenciais.esperar,
);
}
/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes: TokenDoIntegrador): SessaoDoIntegrador {
return new SessaoDoIntegrador(
opcoes.geohub,
opcoes.token,
opcoes.integradorId,
opcoes.buscar,
opcoes.esperar,
);
}
/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
readonly autoria: Autoria;
/** O `fetch` desta sessão. É ele que o SDK recebe. */
readonly buscar: typeof fetch;
/**
* O token da sessão de serviço.
*
* Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
* })`) — e porque esconder um segredo atrás de um método privado não o
* torna menos segredo: o que protege é de onde ele veio.
*/
readonly token: string;/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(credenciais) {
const buscar = credenciais.buscar ?? globalThis.fetch;
const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
body: JSON.stringify({
email: credenciais.email,
password: credenciais.senha,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
const corpo = await resposta.json();
const token = corpo.data?.access_token;
if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
return new SessaoDoIntegrador(
credenciais.geohub,
token,
credenciais.integradorId,
credenciais.buscar,
credenciais.esperar,
);
}
/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes) {
return new SessaoDoIntegrador(
opcoes.geohub,
opcoes.token,
opcoes.integradorId,
opcoes.buscar,
opcoes.esperar,
);
}
/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
autoria;
/** O `fetch` desta sessão. É ele que o SDK recebe. */
buscar;
/**
* O token da sessão de serviço.
*
* Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
* })`) — e porque esconder um segredo atrás de um método privado não o
* torna menos segredo: o que protege é de onde ele veio.
*/
token;curl -X POST https://geohub.exemplo.gov.br/auth/login -H "Content-Type: application/json" \
-d '{"email":"<usuário de serviço>","password":"…","mode":"json"}'
# {"data":{"access_token":"…","refresh_token":"…","expires":900000}}
# (ou o token estático do usuário de serviço, direto como Bearer)O papel Integração - Sistema de despacho concede exatamente a superfície deste guia — e nada além:
| o que o papel alcança | verbos |
|---|---|
| pôr policial em serviço | registrarApresentacao, iniciarJornada, encerrarJornada |
| montar e preparar a | constituirGuarnicao, substituirIntegrante, dissolverGuarnicao, assumirComando, declararProntidao, retirarDeProntidao |
| a ocorrência | registrarOcorrencia, atualizarOcorrencia, declararControlada, encerrarOcorrencia, cancelarOcorrencia |
| o despacho | despachar, cancelarDespacho |
| leitura | obterOcorrencia, listarOcorrenciasAbertas, listarDespachos, listarDespachosDaGuarnicao, obterDespacho, listarRegistro, e as leituras de jornada e |
É a tabela ALCANCE_DO_INTEGRADOR_NA_OCORRENCIA e a ALCANCE_DO_INTEGRADOR_NA_JORNADA da exposição: um teste reprova se elas divergirem do porteiro, e esta página é escrita à mão a partir delas.
O que ele não alcança é tão importante quanto: pausa (iniciarPausa, encerrarPausa, iniciarPausaDaGuarnicao) e são do policial e do supervisor; liberarPolicial é do supervisor; e aceitarDespacho, recusarDespacho, iniciarDeslocamento, registrarChegada, registrarAcao, atualizarSituacao, solicitarApoio, encerrarDespacho e registrarOcorrenciaEmCampo são da equipe. O CAD não aceita despacho pela guarnição — quem está na viatura é quem diz que aceitou, que está a caminho e que chegou.
Uma chamada fora dessa lista volta 403 com código SEM_CAPACIDADE (o papel não alcança o verbo) ou GUARDA_DE_DOMINIO (o papel alcança, mas aquele objeto não é seu).
Idempotência, e os três ids que não se confundem
Comando de integrador exige chave de idempotência, um UUID no cabeçalho x-solicitacao-id. Sem ela a resposta é 400 SOLICITACAO_AUSENTE; fora do formato UUID, 400 SOLICITACAO_INVALIDA.
/**
* Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
* chave** enquanto o comando não tem desfecho.
*
* Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
* `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
* o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
* repeti-la seria martelar quem já respondeu.
*/
async comando<T>(
executar: (solicitacaoId: string) => Promise<T>,
tentativas = 3,
): Promise<T> {
const solicitacaoId = uuidV7();
let ultima: unknown;
for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
try {
return await executar(solicitacaoId);
} catch (erro) {
const segundos = segundosSeEmAndamento(erro);
if (segundos !== undefined) {
await this.esperar(segundos * 1000);
} else if (!(erro instanceof TypeError)) {
throw erro;
}
ultima = erro;
}
}
throw ultima;
}/**
* Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
* chave** enquanto o comando não tem desfecho.
*
* Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
* `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
* o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
* repeti-la seria martelar quem já respondeu.
*/
async comando(executar, tentativas = 3) {
const solicitacaoId = uuidV7();
let ultima;
for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
try {
return await executar(solicitacaoId);
} catch (erro) {
const segundos = segundosSeEmAndamento(erro);
if (segundos !== undefined) {
await this.esperar(segundos * 1000);
} else if (!(erro instanceof TypeError)) {
throw erro;
}
ultima = erro;
}
}
throw ultima;
}curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencias/$OCORRENCIA/despachos -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f" \
-H 'x-autoria: {"tipo":"integrador","id":"'$INTEGRADOR'"}' \
-d '{"recurso":{"tipo":"guarnicao","guarnicaoId":"…","viatura":"RP-1207","composicao":[…],"identificador":"RP-1207"}}'
# {"dados":"<despachoId>"} (repetir a mesma chave devolve o mesmo, com "repetido":true)
# 409 {"erro":{"codigo":"SOLICITACAO_EM_ANDAMENTO"}} Retry-After: 2Repetir a mesma chave devolve o mesmo desfecho, com repetido: true, e não executa de novo. Isso é o que torna o retry seguro — e é por isso que a chave tem de ser cunhada antes da primeira tentativa, não dentro dela.
O retry pode encontrar a chave em três estados além do "já concluído":
| resposta | quando | o que fazer |
|---|---|---|
409 SOLICITACAO_EM_ANDAMENTO | o primeiro pedido com a mesma chave ainda executa (o seu timeout chegou antes da resposta) | espere o Retry-After e repita com a mesma chave: aí vem o desfecho guardado |
422 SOLICITACAO_REUSADA | a chave já foi usada em outro comando — outro verbo, outro corpo ou outro integrador | é defeito do seu lado: cunhe uma chave por comando. O desfecho do outro comando não é devolvido |
executa de novo (repetido ausente) | o primeiro explodiu (exceção do servidor, não falha de negócio), ou ficou em andamento mais de 5 min — o processo que o atendia caiu | nada: a chave volta a valer para o mesmo comando, e o Geohub guarda o rastro da tentativa que falhou |
O 409 não é desfecho: nada executou de novo, e repetir a mesma chave depois do Retry-After é seguro. O comando do exemplo faz isso, e só isso — falha de rede e 409 SOLICITACAO_EM_ANDAMENTO repetem a chave; qualquer outra resposta é desfecho.
/**
* UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
*
* Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
* **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
* um índice que se lê em ordem de chegada.
*/
export function uuidV7(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
const agora = Date.now();
for (let i = 0; i < 6; i += 1) {
bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
}
bytes[6] = ((bytes[6] as number) & 0x0f) | 0x70;
bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
}/**
* UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
*
* Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
* **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
* um índice que se lê em ordem de chegada.
*/
export function uuidV7() {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
const agora = Date.now();
for (let i = 0; i < 6; i += 1) {
bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
}
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
}# Um UUID v7 por comando, cunhado antes da primeira tentativa:
curl -X POST … -H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f"Três ids circulam, e trocá-los é a confusão mais comum:
| id | quem cunha | para quê |
|---|---|---|
solicitacaoId | você, um por comando | idempotência. Vai no cabeçalho x-solicitacao-id |
origem.solicitacao | o Geohub, copiando o seu | correlacionar o evento com o comando que o causou |
id do envelope | o Geohub, um por evento | deduplicação na entrega. Vai no corpo e no X-Geohub-Evento-Id |
O id do envelope não é o solicitacaoId: um comando causa vários eventos, e cada um tem o seu. Deduplicar pelo solicitacaoId descartaria eventos legítimos.
O desfecho do comando é a resposta HTTP
Na REST gerada e no SDK, o comando é síncrono: ele responde 200 com o que o verbo devolveu (o ocorrenciaId, o despachoId) ou com o código da falha de negócio. Não espere um evento integracao:solicitacao-aceita para saber que deu certo — aquele evento pertence à API assíncrona anterior, que respondia 202.
Ids do integrador e tradução
O Geohub fala os ids dele: ocorrenciaId, despachoId, guarnicaoId e jornadaId são UUIDs cunhados por ele. A viatura é a exceção — ela viaja pelo prefixo operacional (RP-1207), que é o identificador que o conhece.
Para reconhecer a própria ocorrência num evento, mande referenciaExterna no registro: é o seu protocolo, guardado no Geohub e devolvido no agregado. É mais barato que manter uma tabela de-para do seu lado — e sobrevive a um restart do seu serviço. Para a sessão de integrador ela é obrigatória (sem ela, 403 GUARDA_DE_DOMINIO), e imutável: o PATCH não a aceita.
Os ids de pessoa (usuario) viajam como a matrícula do policial no seu sistema, e a exposição os traduz para o UsuarioId do Geohub pela coleção integrador_identidade (integrador, tipo: policial, id_externo, id_geohub), cadastrada pelo administrador do Geohub, uma linha por policial. Você só lê a tabela; sem a linha, o comando que cita o policial volta integrante-ausente. O cadastro dos policiais e das traduções é anterior à integração e não é assunto deste guia.
Pôr o policial em serviço e indicar a viatura
Sem guarnição em não há a quem despachar. O pool de despacho é formado por guarnições com status disponivel, e uma guarnição só chega lá depois de quatro atos.
A jornada é da pessoa; a viatura é da guarnição. Quem serve na central tem jornada igual, só não entra em guarnição.
/**
* Põe UM policial em serviço: apresentação (UC-06) e início (UC-14).
*
* São dois verbos, e não um: a apresentação é o comparecimento à unidade,
* e entre ela e o início de serviço existe um intervalo real (RS-02) —
* quem se apresentou ainda não trabalha. O CAD que só tem "entrou de
* serviço" chama os dois em seguida, como aqui; o que distingue os dois
* momentos chama cada um no seu.
*/
async porEmServico(
matricula: Matricula,
posto: PostoOcupado,
): Promise<JornadaId> {
const apresentada = await this.jornada.registrarApresentacao(
usuarioDaMatricula(matricula),
);
const jornadaId = this.exigir(apresentada, 'registrarApresentacao');
this.exigir(
await this.jornada.iniciarJornada({ id: jornadaId, posto }),
'iniciarJornada',
);
return jornadaId;
}/**
* Põe UM policial em serviço: apresentação (UC-06) e início (UC-14).
*
* São dois verbos, e não um: a apresentação é o comparecimento à unidade,
* e entre ela e o início de serviço existe um intervalo real (RS-02) —
* quem se apresentou ainda não trabalha. O CAD que só tem "entrou de
* serviço" chama os dois em seguida, como aqui; o que distingue os dois
* momentos chama cada um no seu.
*/
async porEmServico(matricula, posto) {
const apresentada = await this.jornada.registrarApresentacao(
usuarioDaMatricula(matricula),
);
const jornadaId = this.exigir(apresentada, 'registrarApresentacao');
this.exigir(
await this.jornada.iniciarJornada({ id: jornadaId, posto }),
'iniciarJornada',
);
return jornadaId;
}A apresentação e o início são dois verbos de propósito: entre comparecer à unidade e entrar em serviço existe um intervalo real. Um CAD que só registre "entrou de serviço" chama os dois em seguida, como acima.
/**
* Monta a guarnição na viatura e a põe no pool de despacho.
*
* A ordem não é negociável e o Geohub a cobra: `declararProntidao` antes
* de `assumirComando` devolve `comando-nao-assumido`, e `assumirComando`
* com a jornada do comandante ainda `apresentada` devolve
* `jornada-nao-iniciada`. As duas são falhas de **negócio** — vêm como
* resultado, com código, e dizem exatamente o que falta.
*/
async constituirGuarnicao(
viatura: ViaturaId,
equipe: readonly PolicialEmServico[],
): Promise<GuarnicaoId> {
const comandante = equipe.find((p) => p.posto === 'comandante');
if (comandante === undefined) {
throw new FalhaDaEscala(
SEM_POSTO_DE_COMANDANTE,
'constituirGuarnicao',
'a composição precisa de um comandante',
);
}
const guarnicaoId = this.exigir(
await this.jornada.constituirGuarnicao({
composicao: equipe.map((p) => ({
posto: p.posto,
usuario: usuarioDaMatricula(p.matricula),
})),
viatura,
}),
'constituirGuarnicao',
);
this.exigir(
await this.jornada.assumirComando({
guarnicaoId,
jornadaId: comandante.jornadaId,
}),
'assumirComando',
);
this.exigir(
await this.jornada.declararProntidao(guarnicaoId),
'declararProntidao',
);
return guarnicaoId;
}/**
* Monta a guarnição na viatura e a põe no pool de despacho.
*
* A ordem não é negociável e o Geohub a cobra: `declararProntidao` antes
* de `assumirComando` devolve `comando-nao-assumido`, e `assumirComando`
* com a jornada do comandante ainda `apresentada` devolve
* `jornada-nao-iniciada`. As duas são falhas de **negócio** — vêm como
* resultado, com código, e dizem exatamente o que falta.
*/
async constituirGuarnicao(viatura, equipe) {
const comandante = equipe.find((p) => p.posto === 'comandante');
if (comandante === undefined) {
throw new FalhaDaEscala(
SEM_POSTO_DE_COMANDANTE,
'constituirGuarnicao',
'a composição precisa de um comandante',
);
}
const guarnicaoId = this.exigir(
await this.jornada.constituirGuarnicao({
composicao: equipe.map((p) => ({
posto: p.posto,
usuario: usuarioDaMatricula(p.matricula),
})),
viatura,
}),
'constituirGuarnicao',
);
this.exigir(
await this.jornada.assumirComando({
guarnicaoId,
jornadaId: comandante.jornadaId,
}),
'assumirComando',
);
this.exigir(
await this.jornada.declararProntidao(guarnicaoId),
'declararProntidao',
);
return guarnicaoId;
}A ordem é cobrada pelo domínio, e as recusas dizem exatamente o que falta: declararProntidao antes de assumirComando devolve comando-nao-assumido; assumirComando com a jornada do ainda apresentada devolve jornada-nao-iniciada.
| rota | verbo |
|---|---|
POST /plugins/policiais/{usuario}/apresentacao | registrarApresentacao |
POST /plugins/jornadas/{id}/inicio | iniciarJornada |
POST /plugins/guarnicoes | constituirGuarnicao |
POST /plugins/guarnicoes/{guarnicaoId}/comando | assumirComando |
POST /plugins/guarnicoes/{guarnicaoId}/prontidao | declararProntidao |
GET /plugins/viaturas/{viatura}/guarnicao-disponivel | guarnicaoDisponivelNaViatura |
Os dois transportes, lado a lado
Pelo SDK, não há caminho nenhum escrito — o cliente é o IJornadaHub:
/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao: SessaoDoIntegrador): PortaDeJornada {
const cliente = criarClienteJornada({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
/** Os comandos presos à chave de idempotência do comando: o retry a repete. */
const comando = (solicitacaoId: string) =>
cliente.comSolicitacao(solicitacaoId);
return {
assumirComando: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).assumirComando(dados)),
),
constituirGuarnicao: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).constituirGuarnicao(dados)),
),
declararProntidao: (guarnicaoId) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
),
guarnicaoDisponivelNaViatura: (viatura) =>
cliente.guarnicaoDisponivelNaViatura(viatura),
iniciarJornada: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).iniciarJornada(dados)),
),
registrarApresentacao: (usuario) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarApresentacao(usuario)),
),
};
}/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao) {
const cliente = criarClienteJornada({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
/** Os comandos presos à chave de idempotência do comando: o retry a repete. */
const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
return {
assumirComando: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).assumirComando(dados)),
),
constituirGuarnicao: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).constituirGuarnicao(dados)),
),
declararProntidao: (guarnicaoId) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
),
guarnicaoDisponivelNaViatura: (viatura) =>
cliente.guarnicaoDisponivelNaViatura(viatura),
iniciarJornada: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).iniciarJornada(dados)),
),
registrarApresentacao: (usuario) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarApresentacao(usuario)),
),
};
}Pela REST, os caminhos são os da spec, e os três cabeçalhos são o protocolo inteiro:
/** O plugin `jornada` pelos caminhos da OpenAPI. */
export function jornadaPorRest(sessao: SessaoDoIntegrador): PortaDeJornada {
return {
assumirComando: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar(
'POST',
url`/guarnicoes/${dados.guarnicaoId}/comando`,
{ corpo: { jornadaId: dados.jornadaId }, solicitacaoId },
),
nada,
falhaDeJornada,
),
),
constituirGuarnicao: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe<GuarnicaoId, FalhaDeJornada>(
await sessao.requisitar('POST', '/guarnicoes', {
corpo: dados,
solicitacaoId,
}),
(id) => guarnicaoId(texto(id)),
falhaDeJornada,
),
),
declararProntidao: (id) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', url`/guarnicoes/${id}/prontidao`, {
solicitacaoId,
}),
nada,
falhaDeJornada,
),
),
// Leitura não leva `solicitacaoId`: consultar não é agir, e repetir um
// `GET` não muda nada.
guarnicaoDisponivelNaViatura: async (viatura) => {
const resposta = await sessao.requisitar(
'GET',
url`/viaturas/${viatura}/guarnicao-disponivel`,
);
const desfecho = await desfechoDe(
resposta,
guarnicaoElegivel,
falhaDeJornada,
);
return desfecho.aceito ? desfecho.valor : undefined;
},
iniciarJornada: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', url`/jornadas/${dados.id}/inicio`, {
corpo: { posto: dados.posto },
solicitacaoId,
}),
nada,
falhaDeJornada,
),
),
registrarApresentacao: (usuario) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe<JornadaId, FalhaDeJornada>(
await sessao.requisitar(
'POST',
url`/policiais/${usuario}/apresentacao`,
{ solicitacaoId },
),
(id) => jornadaId(texto(id)),
falhaDeJornada,
),
),
};
}/** O plugin `jornada` pelos caminhos da OpenAPI. */
export function jornadaPorRest(sessao) {
return {
assumirComando: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar(
'POST',
url`/guarnicoes/${dados.guarnicaoId}/comando`,
{ corpo: { jornadaId: dados.jornadaId }, solicitacaoId },
),
nada,
falhaDeJornada,
),
),
constituirGuarnicao: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', '/guarnicoes', {
corpo: dados,
solicitacaoId,
}),
(id) => guarnicaoId(texto(id)),
falhaDeJornada,
),
),
declararProntidao: (id) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', url`/guarnicoes/${id}/prontidao`, {
solicitacaoId,
}),
nada,
falhaDeJornada,
),
),
// Leitura não leva `solicitacaoId`: consultar não é agir, e repetir um
// `GET` não muda nada.
guarnicaoDisponivelNaViatura: async (viatura) => {
const resposta = await sessao.requisitar(
'GET',
url`/viaturas/${viatura}/guarnicao-disponivel`,
);
const desfecho = await desfechoDe(
resposta,
guarnicaoElegivel,
falhaDeJornada,
);
return desfecho.aceito ? desfecho.valor : undefined;
},
iniciarJornada: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', url`/jornadas/${dados.id}/inicio`, {
corpo: { posto: dados.posto },
solicitacaoId,
}),
nada,
falhaDeJornada,
),
),
registrarApresentacao: (usuario) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar(
'POST',
url`/policiais/${usuario}/apresentacao`,
{ solicitacaoId },
),
(id) => jornadaId(texto(id)),
falhaDeJornada,
),
),
};
}/**
* O `fetch` autenticado da REST gerada — o que o integrador em outra
* linguagem escreveria à mão a partir da OpenAPI.
*
* Os três cabeçalhos são o protocolo inteiro: o token diz quem entrou, o
* `x-autoria` diz em nome de quem se age (e vai para ser **conferido**,
* nunca para mandar) e o `x-solicitacao-id` é a chave de idempotência. O
* corpo é o argumento do verbo, e nada mais.
*/
async requisitar(
metodo: 'GET' | 'PATCH' | 'POST',
caminho: string,
opcoes: { corpo?: unknown; solicitacaoId?: string } = {},
): Promise<Response> {
const temCorpo = opcoes.corpo !== undefined;
return this.buscar(`${this.plugins}${caminho}`, {
...(temCorpo ? { body: JSON.stringify(opcoes.corpo) } : {}),
headers: {
Authorization: `Bearer ${this.token}`,
...(temCorpo ? { 'Content-Type': 'application/json' } : {}),
...(metodo === 'GET'
? {}
: { 'x-autoria': JSON.stringify(this.autoria) }),
...(opcoes.solicitacaoId === undefined
? {}
: { 'x-solicitacao-id': opcoes.solicitacaoId }),
},
method: metodo,
});
}/**
* O `fetch` autenticado da REST gerada — o que o integrador em outra
* linguagem escreveria à mão a partir da OpenAPI.
*
* Os três cabeçalhos são o protocolo inteiro: o token diz quem entrou, o
* `x-autoria` diz em nome de quem se age (e vai para ser **conferido**,
* nunca para mandar) e o `x-solicitacao-id` é a chave de idempotência. O
* corpo é o argumento do verbo, e nada mais.
*/
async requisitar(metodo, caminho, opcoes = {}) {
const temCorpo = opcoes.corpo !== undefined;
return this.buscar(`${this.plugins}${caminho}`, {
...(temCorpo ? { body: JSON.stringify(opcoes.corpo) } : {}),
headers: {
Authorization: `Bearer ${this.token}`,
...(temCorpo ? { 'Content-Type': 'application/json' } : {}),
...(metodo === 'GET'
? {}
: { 'x-autoria': JSON.stringify(this.autoria) }),
...(opcoes.solicitacaoId === undefined
? {}
: { 'x-solicitacao-id': opcoes.solicitacaoId }),
},
method: metodo,
});
}Registrar e atualizar a ocorrência
/**
* Registra a ocorrência e devolve o id que o Geohub cunhou.
*
* `referenciaExterna` é o protocolo do CAD, e é obrigatória. Mandá-la é o
* que permite, depois, reconhecer a própria ocorrência num evento — e é
* mais barato que manter uma tabela de-para do lado de cá.
*
* Ela também identifica uma só ocorrência por integrador. O CAD que não
* recebeu a resposta e reenvia com `solicitacaoId` novo recebe `409
* referencia-externa-ja-registrada`, com o id da que já existe em
* `contexto.ocorrenciaId`: é com ela que ele segue, sem abrir outra.
*/
async registrar(dados: DadosDeRegistroDeOcorrencia): Promise<OcorrenciaId> {
const desfecho = await this.ocorrencia.registrarOcorrencia(dados);
if (
!desfecho.aceito &&
desfecho.codigo ===
CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
) {
return desfecho.contexto.ocorrenciaId;
}
return this.exigir(desfecho, 'registrarOcorrencia');
}/**
* Registra a ocorrência e devolve o id que o Geohub cunhou.
*
* `referenciaExterna` é o protocolo do CAD, e é obrigatória. Mandá-la é o
* que permite, depois, reconhecer a própria ocorrência num evento — e é
* mais barato que manter uma tabela de-para do lado de cá.
*
* Ela também identifica uma só ocorrência por integrador. O CAD que não
* recebeu a resposta e reenvia com `solicitacaoId` novo recebe `409
* referencia-externa-ja-registrada`, com o id da que já existe em
* `contexto.ocorrenciaId`: é com ela que ele segue, sem abrir outra.
*/
async registrar(dados) {
const desfecho = await this.ocorrencia.registrarOcorrencia(dados);
if (
!desfecho.aceito &&
desfecho.codigo ===
CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
) {
return desfecho.contexto.ocorrenciaId;
}
return this.exigir(desfecho, 'registrarOcorrencia');
}/**
* Complementa os dados de negócio da ocorrência.
*
* Só os de negócio: o status não entra aqui. Ele muda por verbo próprio —
* e, no caso de `em_atendimento`, por consequência da primeira chegada ao
* local. Um `PATCH` que aceitasse `status` convidaria o CAD a escolher o
* próximo estado, que não é escolha dele.
*/
async atualizar(
id: OcorrenciaId,
alteracoes: AlteracoesDeOcorrencia,
): Promise<void> {
this.exigir(
await this.ocorrencia.atualizarOcorrencia({ alteracoes, id }),
'atualizarOcorrencia',
);
}/**
* Complementa os dados de negócio da ocorrência.
*
* Só os de negócio: o status não entra aqui. Ele muda por verbo próprio —
* e, no caso de `em_atendimento`, por consequência da primeira chegada ao
* local. Um `PATCH` que aceitasse `status` convidaria o CAD a escolher o
* próximo estado, que não é escolha dele.
*/
async atualizar(id, alteracoes) {
this.exigir(
await this.ocorrencia.atualizarOcorrencia({ alteracoes, id }),
'atualizarOcorrencia',
);
}curl -X PATCH https://geohub.exemplo.gov.br/plugins/ocorrencias/$OCORRENCIA -H "Authorization: Bearer $TOKEN" -H "x-solicitacao-id: $(uuidv7)" -H "Content-Type: application/json" \
-d '{"alteracoes":{"descricao":"dois suspeitos"}}'
# {"dados":null}O PATCH complementa só dados de negócio. O status não entra: ele muda por verbo próprio, e em_atendimento muda por consequência da primeira chegada ao local. A referenciaExterna também não: ela é a identidade da ocorrência no seu sistema. Campo desconhecido no corpo é 400, não ruído tolerado.
Uma referência, uma ocorrência
A referenciaExterna identifica uma só ocorrência por integrador. A idempotência por solicitacaoId cobre o retry com a mesma chave; a referência cobre o caso que ela não alcança — o CAD que não recebeu a resposta e reenvia com chave nova. Esse reenvio não abre uma segunda ocorrência: a resposta é 409 referencia-externa-ja-registrada, com o id da que já existe em erro.contexto.ocorrenciaId, e é com ela que você segue.
{
"erro": {
"codigo": "referencia-externa-ja-registrada",
"contexto": { "ocorrenciaId": "0199…" },
"mensagem": "esta referência externa já identifica uma ocorrência"
}
}A unicidade é por integrador: outro sistema pode usar o mesmo número de protocolo sem colidir com o seu. Quem registra pelo próprio Geohub (a central, o policial em campo) é um originador só.
| rota | verbo |
|---|---|
POST /plugins/ocorrencias | registrarOcorrencia |
PATCH /plugins/ocorrencias/{id} | atualizarOcorrencia |
GET /plugins/ocorrencias/{id} | obterOcorrencia |
GET /plugins/ocorrencias/{ocorrenciaId}/registro | listarRegistro |
Pedir despacho
Duas coisas valem o guia inteiro:
O despacho é por RECURSO, não por ocorrência
Cada viatura empenhada é um despacho, com despachoId e status próprios. Uma ocorrência pode ter vários — e o que acontece com um não diz nada sobre os outros.
A viatura não é o recurso; a guarnição dela é
O despachar recebe um já resolvido. Quem traduz "viatura RP-1207" em "a guarnição que está nela agora" é o plugin jornada. Por isso "viatura sem guarnição disponível" é um resultado, não um 404.
/**
* Empenha a guarnição de uma viatura, e devolve o `DespachoId`.
*
* O desfecho é **valor**: `viatura-sem-guarnicao-disponivel` é a resposta
* normal para uma viatura que não tem equipe pronta agora, e o despachante
* a trata escolhendo outra viatura. Lançar exceção aqui faria o caso mais
* comum do dia parecer um incidente.
*/
async despacharViatura(
ocorrencia: OcorrenciaId,
viatura: ViaturaId,
instrucoes?: string,
): Promise<Desfecho<DespachoId, FalhaDaViatura | FalhaDeOcorrencia>> {
const guarnicao = await this.escala.guarnicaoDisponivelNaViatura(viatura);
if (guarnicao === undefined) {
return {
aceito: false,
codigo: VIATURA_SEM_GUARNICAO_DISPONIVEL,
detalhe: `a viatura ${viatura} não tem guarnição disponível`,
};
}
return this.ocorrencia.despachar({
...(instrucoes === undefined ? {} : { instrucoes }),
ocorrenciaId: ocorrencia,
recurso: recursoDaGuarnicao(guarnicao),
});
}/**
* Empenha a guarnição de uma viatura, e devolve o `DespachoId`.
*
* O desfecho é **valor**: `viatura-sem-guarnicao-disponivel` é a resposta
* normal para uma viatura que não tem equipe pronta agora, e o despachante
* a trata escolhendo outra viatura. Lançar exceção aqui faria o caso mais
* comum do dia parecer um incidente.
*/
async despacharViatura(ocorrencia, viatura, instrucoes) {
const guarnicao = await this.escala.guarnicaoDisponivelNaViatura(viatura);
if (guarnicao === undefined) {
return {
aceito: false,
codigo: VIATURA_SEM_GUARNICAO_DISPONIVEL,
detalhe: `a viatura ${viatura} não tem guarnição disponível`,
};
}
return this.ocorrencia.despachar({
...(instrucoes === undefined ? {} : { instrucoes }),
ocorrenciaId: ocorrencia,
recurso: recursoDaGuarnicao(guarnicao),
});
}| rota | verbo |
|---|---|
POST /plugins/ocorrencias/{ocorrenciaId}/despachos | despachar |
GET /plugins/ocorrencias/{ocorrenciaId}/despachos | listarDespachos |
GET /plugins/despachos/{id} | obterDespacho |
POST /plugins/despachos/{id}/cancelamento | cancelarDespacho |
E os dois transportes da ocorrência, lado a lado:
/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(
sessao: SessaoDoIntegrador,
): PortaDeOcorrencia {
const cliente = criarClienteOcorrencia({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
const comando = (solicitacaoId: string) =>
cliente.comSolicitacao(solicitacaoId);
return {
atualizarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
),
despachar: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).despachar(dados)),
),
registrarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarOcorrencia(dados)),
),
};
}/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(sessao) {
const cliente = criarClienteOcorrencia({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
return {
atualizarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
),
despachar: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).despachar(dados)),
),
registrarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarOcorrencia(dados)),
),
};
}/** O plugin `ocorrencia` pelos caminhos da OpenAPI. */
export function ocorrenciaPorRest(
sessao: SessaoDoIntegrador,
): PortaDeOcorrencia {
return {
atualizarOcorrencia: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('PATCH', url`/ocorrencias/${dados.id}`, {
corpo: { alteracoes: dados.alteracoes },
solicitacaoId,
}),
nada,
falhaDeOcorrencia,
),
),
despachar: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe<DespachoId, FalhaDeOcorrencia>(
await sessao.requisitar(
'POST',
url`/ocorrencias/${dados.ocorrenciaId}/despachos`,
{
corpo: {
...(dados.instrucoes === undefined
? {}
: { instrucoes: dados.instrucoes }),
recurso: dados.recurso,
},
solicitacaoId,
},
),
(id) => despachoId(texto(id)),
falhaDeOcorrencia,
),
),
registrarOcorrencia: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe<OcorrenciaId, FalhaDeOcorrencia>(
await sessao.requisitar('POST', '/ocorrencias', {
corpo: dados,
solicitacaoId,
}),
(id) => ocorrenciaId(texto(id)),
falhaDeOcorrencia,
),
),
};
}/** O plugin `ocorrencia` pelos caminhos da OpenAPI. */
export function ocorrenciaPorRest(sessao) {
return {
atualizarOcorrencia: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('PATCH', url`/ocorrencias/${dados.id}`, {
corpo: { alteracoes: dados.alteracoes },
solicitacaoId,
}),
nada,
falhaDeOcorrencia,
),
),
despachar: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar(
'POST',
url`/ocorrencias/${dados.ocorrenciaId}/despachos`,
{
corpo: {
...(dados.instrucoes === undefined
? {}
: { instrucoes: dados.instrucoes }),
recurso: dados.recurso,
},
solicitacaoId,
},
),
(id) => despachoId(texto(id)),
falhaDeOcorrencia,
),
),
registrarOcorrencia: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', '/ocorrencias', {
corpo: dados,
solicitacaoId,
}),
(id) => ocorrenciaId(texto(id)),
falhaDeOcorrencia,
),
),
};
}Receber os eventos
O Geohub entrega no seu callback, por POST, o envelope do plugin — o mesmo objeto que o GET de eventos devolve a uma sessão:
{
"id": "0199bb3e-0000-7000-8000-00000000004a",
"ocorridoEm": "2026-10-31T12:04:11.000Z",
"origem": {
"autoria": { "id": "…", "tipo": "usuario" },
"plugin": "ocorrencia",
"solicitacao": "…",
"versao": "1.0.0"
},
"payload": {
"de": "em_deslocamento",
"id": "…",
"ocorrenciaId": "…",
"para": "no_local"
},
"tipo": "ocorrencia:despacho-mudou-status"
}O metadado de entrega vai em cabeçalho, nunca no corpo — pô-lo no corpo daria duas formas ao mesmo evento, já que o corpo é o mesmo envelope que o polling entrega:
const CABECALHO = {
AGREGADO: 'x-geohub-agregado',
ASSINATURA: 'x-geohub-assinatura',
EVENTO_ID: 'x-geohub-evento-id',
SEQUENCIA: 'x-geohub-sequencia',
TIMESTAMP: 'x-geohub-timestamp',
} as const;
/** O plugin do DESFECHO do comando — a resposta, não o eco. */
const PLUGIN_DE_INTEGRACAO = 'integracao';
/** ±5 min, dos dois lados: relógio adiantado é tão comum quanto atrasado. */
const TOLERANCIA_EM_SEGUNDOS = 300;const CABECALHO = {
AGREGADO: 'x-geohub-agregado',
ASSINATURA: 'x-geohub-assinatura',
EVENTO_ID: 'x-geohub-evento-id',
SEQUENCIA: 'x-geohub-sequencia',
TIMESTAMP: 'x-geohub-timestamp',
};
/** O plugin do DESFECHO do comando — a resposta, não o eco. */
const PLUGIN_DE_INTEGRACAO = 'integracao';
/** ±5 min, dos dois lados: relógio adiantado é tão comum quanto atrasado. */
const TOLERANCIA_EM_SEGUNDOS = 300;# O que o Geohub manda ao seu callback_url:
POST https://cad.exemplo.gov.br/geohub/eventos
Content-Type: application/json
X-Geohub-Evento-Id: 0199bb3e-0000-7000-8000-00000000004a
X-Geohub-Timestamp: 1761912251
X-Geohub-Assinatura: sha256=<hex do HMAC-SHA256(segredo, "1761912251." + corpo cru)>
X-Geohub-Agregado: ocorrencia:0199…
X-Geohub-Sequencia: 4
{ …o envelope… }
# responda 204A tabela de eventos
tipo | quando acontece | payload |
|---|---|---|
ocorrencia:registrada | a ocorrência nasceu | { ocorrencia } — o agregado inteiro |
ocorrencia:atualizada | dados de negócio mudaram | { id, alteracoes, versao } |
ocorrencia:mudou-status | a OCORRÊNCIA mudou de status | { id, de, para, motivo?, versao } |
ocorrencia:despachada | um recurso foi empenhado | { despacho } — o agregado do despacho |
ocorrencia:despacho-mudou-status | UM despacho mudou: aceito, em , no local, encerrado | { id, ocorrenciaId, de, para, motivo?, resultado? } |
ocorrencia:registro-adicionado | ação, situação ou pedido de apoio na narrativa | { ocorrenciaId, entrada } |
jornada:registrada | policial se apresentou | { jornada } |
jornada:mudou-status | jornada entrou em serviço, pausou, encerrou | { id, de, para, posto?, versao } |
jornada:guarnicao-constituida | guarnição montada numa viatura | { guarnicao } |
jornada:comando-assumido | comandante assumiu | { id, comandante, versao } |
jornada:guarnicao-mudou-status | a guarnição entrou no pool, foi designada, chegou | { id, de, para, despachoId?, versao } |
jornada:pausa-registrada / jornada:pausa-mudou-status | pausa de pessoa ou de equipe | { pausa } / { id, de, para, motivo? } |
A chegada ao local não é um evento próprio
Não existe um viatura-chegou. Chegada ao local é a mudança de status de um despacho para no_local (ocorrencia:despacho-mudou-status), e da guarnição dele (jornada:guarnicao-mudou-status).
E a consequência que custa caro quando se erra:
Uma viatura no local não é a ocorrência no local
A primeira chegada leva a ocorrência a em_atendimento (ocorrencia:mudou-status). As demais viaturas podem continuar em_deslocamento — e continuam. Guarde o estado por despachoId, e nunca infira o estado dos outros recursos a partir de um deles. O status da ocorrência e o de cada despacho são coisas diferentes.
/**
* O estado de UM despacho.
*
* Nunca o estado "da ocorrência no local": `no_local` é propriedade do
* despacho de uma viatura, e a ocorrência pode ter outra ainda em
* deslocamento.
*/
despacho(id: DespachoId): EstadoDoDespacho | undefined {
return this.despachos.get(id);
}/**
* O estado de UM despacho.
*
* Nunca o estado "da ocorrência no local": `no_local` é propriedade do
* despacho de uma viatura, e a ocorrência pode ter outra ainda em
* deslocamento.
*/
despacho(id) {
return this.despachos.get(id);
}/**
* A projeção: o que o CAD mostra na tela.
*
* Três tipos bastam para o atendimento, e os três dizem coisas
* diferentes:
*
* - `ocorrencia:despachada` — nasceu um despacho. É aqui que o
* `despachoId` aparece pela primeira vez;
* - `ocorrencia:despacho-mudou-status` — a chegada ao local é esta, com
* `para: 'no_local'`. Não existe um `viatura-chegou`;
* - `ocorrencia:mudou-status` — a OCORRÊNCIA mudou. `em_atendimento` é
* consequência da PRIMEIRA chegada, e não diz nada sobre os outros
* despachos.
*/
private aplicar(envelope: EnvelopeDoGeohub): void {
this.aplicados.push(envelope);
const payload = envelope.payload;
if (envelope.tipo === 'ocorrencia:despachada') {
const despacho = payload['despacho'] as
| {
id: string;
ocorrenciaId: string;
recurso?: { identificador?: string };
status: string;
}
| undefined;
if (despacho === undefined) return;
const id = despachoId(despacho.id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(despacho.ocorrenciaId),
...(despacho.recurso?.identificador === undefined
? {}
: { recurso: despacho.recurso.identificador }),
status: despacho.status,
});
return;
}
if (envelope.tipo === 'ocorrencia:despacho-mudou-status') {
const id = despachoId(String(payload['id']));
const anterior = this.despachos.get(id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(String(payload['ocorrenciaId'])),
...(anterior?.recurso === undefined
? {}
: { recurso: anterior.recurso }),
status: String(payload['para']),
});
return;
}
if (envelope.tipo === 'ocorrencia:mudou-status') {
this.ocorrencias.set(
ocorrenciaId(String(payload['id'])),
String(payload['para']),
);
}
}/**
* A projeção: o que o CAD mostra na tela.
*
* Três tipos bastam para o atendimento, e os três dizem coisas
* diferentes:
*
* - `ocorrencia:despachada` — nasceu um despacho. É aqui que o
* `despachoId` aparece pela primeira vez;
* - `ocorrencia:despacho-mudou-status` — a chegada ao local é esta, com
* `para: 'no_local'`. Não existe um `viatura-chegou`;
* - `ocorrencia:mudou-status` — a OCORRÊNCIA mudou. `em_atendimento` é
* consequência da PRIMEIRA chegada, e não diz nada sobre os outros
* despachos.
*/
aplicar(envelope) {
this.aplicados.push(envelope);
const payload = envelope.payload;
if (envelope.tipo === 'ocorrencia:despachada') {
const despacho = payload['despacho'];
if (despacho === undefined) return;
const id = despachoId(despacho.id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(despacho.ocorrenciaId),
...(despacho.recurso?.identificador === undefined
? {}
: { recurso: despacho.recurso.identificador }),
status: despacho.status,
});
return;
}
if (envelope.tipo === 'ocorrencia:despacho-mudou-status') {
const id = despachoId(String(payload['id']));
const anterior = this.despachos.get(id);
this.despachos.set(id, {
despachoId: id,
ocorrenciaId: ocorrenciaId(String(payload['ocorrenciaId'])),
...(anterior?.recurso === undefined
? {}
: { recurso: anterior.recurso }),
status: String(payload['para']),
});
return;
}
if (envelope.tipo === 'ocorrencia:mudou-status') {
this.ocorrencias.set(
ocorrenciaId(String(payload['id'])),
String(payload['para']),
);
}
}Garantias de entrega
A promessa é at-least-once. Todas as obrigações abaixo são do consumidor, e o exemplo tem um teste para cada uma.
Confira a assinatura — sobre o corpo cru
X-Geohub-Assinatura é sha256=<hmac> de <timestamp>.<corpo cru> com o segredo compartilhado. Confira antes de qualquer parse: conferir depois provaria a assinatura de outra coisa. A janela é de ±5 min nos dois sentidos.
/**
* HMAC-SHA256 de `<timestamp>.<corpo cru>`, comparado em tempo constante.
*
* O timestamp entra DENTRO do HMAC: sem isso, trocá-lo seria grátis e a
* janela de replay deixaria de existir.
*/
private assinaturaConfere(
cabecalhos: Record<string, string | undefined>,
corpo: string,
): boolean {
if (!this.janelaConfere(cabecalhos)) return false;
const recebida = /^sha256=(?<hex>[0-9a-f]{64})$/u.exec(
cabecalhos[CABECALHO.ASSINATURA] ?? '',
)?.groups?.['hex'];
if (recebida === undefined) return false;
const esperada = createHmac('sha256', this.opcoes.segredo)
.update(`${cabecalhos[CABECALHO.TIMESTAMP] as string}.${corpo}`, 'utf-8')
.digest('hex');
return timingSafeEqual(
Buffer.from(recebida, 'hex'),
Buffer.from(esperada, 'hex'),
);
}/**
* HMAC-SHA256 de `<timestamp>.<corpo cru>`, comparado em tempo constante.
*
* O timestamp entra DENTRO do HMAC: sem isso, trocá-lo seria grátis e a
* janela de replay deixaria de existir.
*/
assinaturaConfere(cabecalhos, corpo) {
if (!this.janelaConfere(cabecalhos)) return false;
const recebida = /^sha256=(?<hex>[0-9a-f]{64})$/u.exec(
cabecalhos[CABECALHO.ASSINATURA] ?? '',
)?.groups?.['hex'];
if (recebida === undefined) return false;
const esperada = createHmac('sha256', this.opcoes.segredo)
.update(`${cabecalhos[CABECALHO.TIMESTAMP]}.${corpo}`, 'utf-8')
.digest('hex');
return timingSafeEqual(
Buffer.from(recebida, 'hex'),
Buffer.from(esperada, 'hex'),
);
}# Conferir a assinatura no shell
printf '%s.%s' "$TS" "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO"Deduplique pelo id do envelope, e responda 204
O mesmo evento chega de novo quando a resposta do seu callback se perde. Responder 204 ao repetido é o que faz o Geohub parar de insistir.
/**
* O status que volta ao Geohub é a resposta do protocolo:
*
* - `401` — assinatura ou janela: o Geohub não insiste com quem recusa a
* credencial dele;
* - `400` — corpo que não é envelope: insistir não conserta;
* - `204` — aceito. **Também para o repetido e para o eco**: eles foram
* entregues com sucesso; o que se faz com eles é decisão de quem recebe,
* e responder erro faria o Geohub reenviar para sempre.
*/
private async receber(req: IncomingMessage): Promise<number> {
const corpo = await corpoCruDe(req);
const cabecalhos = req.headers as Record<string, string | undefined>;
// Sobre o corpo CRU e antes de qualquer parse: conferir depois provaria
// a assinatura de outra coisa.
if (!this.assinaturaConfere(cabecalhos, corpo)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: this.janelaConfere(cabecalhos)
? 'assinatura-invalida'
: 'timestamp-fora-da-janela',
});
return 401;
}
const lido: unknown = JSON.parse(corpo);
if (!ehEnvelope(lido)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: 'envelope-invalido',
});
return 400;
}
this.recebidos.push(lido);
if (this.vistos.has(lido.id)) {
this.descartes.push({ eventoId: lido.id, motivo: 'repetido' });
return 204;
}
this.vistos.add(lido.id);
/*
* O eco é sobre fato de DOMÍNIO que este CAD causou. O desfecho
* (`integracao:*`) também vem com a autoria dele — é assim que o Geohub
* sabe a quem entregá-lo —, mas ele é a RESPOSTA ao comando, não o eco
* dele: é por ele que o cliente que perdeu a resposta da chamada
* descobre o id que nasceu (task-550). Descartá-lo seria jogar fora o
* único retorno que o webhook traz.
*/
if (
lido.origem.plugin !== PLUGIN_DE_INTEGRACAO &&
lido.origem.autoria.tipo === 'integrador' &&
lido.origem.autoria.id === this.opcoes.integradorId
) {
this.descartes.push({ eventoId: lido.id, motivo: 'eco-proprio' });
return 204;
}
this.enfileirar(
cabecalhos[CABECALHO.AGREGADO] ?? lido.id,
Number(cabecalhos[CABECALHO.SEQUENCIA]),
lido,
);
return 204;
}/**
* O status que volta ao Geohub é a resposta do protocolo:
*
* - `401` — assinatura ou janela: o Geohub não insiste com quem recusa a
* credencial dele;
* - `400` — corpo que não é envelope: insistir não conserta;
* - `204` — aceito. **Também para o repetido e para o eco**: eles foram
* entregues com sucesso; o que se faz com eles é decisão de quem recebe,
* e responder erro faria o Geohub reenviar para sempre.
*/
async receber(req) {
const corpo = await corpoCruDe(req);
const cabecalhos = req.headers;
// Sobre o corpo CRU e antes de qualquer parse: conferir depois provaria
// a assinatura de outra coisa.
if (!this.assinaturaConfere(cabecalhos, corpo)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: this.janelaConfere(cabecalhos)
? 'assinatura-invalida'
: 'timestamp-fora-da-janela',
});
return 401;
}
const lido = JSON.parse(corpo);
if (!ehEnvelope(lido)) {
this.descartes.push({
eventoId: cabecalhos[CABECALHO.EVENTO_ID],
motivo: 'envelope-invalido',
});
return 400;
}
this.recebidos.push(lido);
if (this.vistos.has(lido.id)) {
this.descartes.push({ eventoId: lido.id, motivo: 'repetido' });
return 204;
}
this.vistos.add(lido.id);
/*
* O eco é sobre fato de DOMÍNIO que este CAD causou. O desfecho
* (`integracao:*`) também vem com a autoria dele — é assim que o Geohub
* sabe a quem entregá-lo —, mas ele é a RESPOSTA ao comando, não o eco
* dele: é por ele que o cliente que perdeu a resposta da chamada
* descobre o id que nasceu (task-550). Descartá-lo seria jogar fora o
* único retorno que o webhook traz.
*/
if (
lido.origem.plugin !== PLUGIN_DE_INTEGRACAO &&
lido.origem.autoria.tipo === 'integrador' &&
lido.origem.autoria.id === this.opcoes.integradorId
) {
this.descartes.push({ eventoId: lido.id, motivo: 'eco-proprio' });
return 204;
}
this.enfileirar(
cabecalhos[CABECALHO.AGREGADO] ?? lido.id,
Number(cabecalhos[CABECALHO.SEQUENCIA]),
lido,
);
return 204;
}Ordene por X-Geohub-Sequencia, dentro do agregado
A sequência é sua, por agregado (ocorrencia:<id>, guarnicao:<id>, jornada:<id>): em cada agregado, o seu cadastro recebe 1, 2, 3… sem buraco. Ela não é global, nem igual para todo integrador — o mesmo evento de guarnição pode chegar a você como 4 e a outro CAD como 7 —, e o Geohub a garante contra gravações concorrentes no próprio banco.
O Geohub tenta entregar em ordem, mas um evento que falha não segura os seguintes — segurar transformaria um callback lento em fila parada para todo mundo. Então o que chega adiantado espera: o buraco na chegada é sempre um evento ainda em reenvio, e ele chega (ou fica no dead letter, de onde a operação o reenvia).
/**
* Aplica em ordem de `sequencia` **dentro do agregado**.
*
* A sequência é deste cadastro, por agregado: 1, 2, 3… sem buraco. O
* Geohub tenta entregar em ordem, mas um evento que falha não segura os
* seguintes — segurar transformaria um callback lento em fila parada.
* Então o buraco na chegada é um evento em reenvio, e o que chega
* adiantado espera por ele.
*
* Sequência ilegível (cabeçalho ausente ou adulterado) aplica na hora: o
* cabeçalho **não** entra no HMAC, e travar a projeção por causa de um
* metadado não provado seria entregar o controle a quem está no caminho.
*/
private enfileirar(
agregado: string,
sequencia: number,
envelope: EnvelopeDoGeohub,
): void {
if (!Number.isFinite(sequencia)) {
this.aplicar(envelope);
return;
}
const ultima = this.sequencias.get(agregado) ?? sequencia - 1;
if (sequencia > ultima + 1) {
const esperando = this.espera.get(agregado) ?? [];
esperando.push(envelope);
this.espera.set(agregado, esperando);
return;
}
this.sequencias.set(agregado, sequencia);
this.aplicar(envelope);
this.drenar(agregado, sequencia);
}/**
* Aplica em ordem de `sequencia` **dentro do agregado**.
*
* A sequência é deste cadastro, por agregado: 1, 2, 3… sem buraco. O
* Geohub tenta entregar em ordem, mas um evento que falha não segura os
* seguintes — segurar transformaria um callback lento em fila parada.
* Então o buraco na chegada é um evento em reenvio, e o que chega
* adiantado espera por ele.
*
* Sequência ilegível (cabeçalho ausente ou adulterado) aplica na hora: o
* cabeçalho **não** entra no HMAC, e travar a projeção por causa de um
* metadado não provado seria entregar o controle a quem está no caminho.
*/
enfileirar(agregado, sequencia, envelope) {
if (!Number.isFinite(sequencia)) {
this.aplicar(envelope);
return;
}
const ultima = this.sequencias.get(agregado) ?? sequencia - 1;
if (sequencia > ultima + 1) {
const esperando = this.espera.get(agregado) ?? [];
esperando.push(envelope);
this.espera.set(agregado, esperando);
return;
}
this.sequencias.set(agregado, sequencia);
this.aplicar(envelope);
this.drenar(agregado, sequencia);
}Descarte o próprio eco — menos o integracao:*
O fato de domínio que o seu CAD causou volta como evento, com origem.autoria igual à dele. Reaplicá-lo duplicaria o que ele já sabe — você recebeu o ocorrenciaId e o despachoId na resposta do comando.
O integracao:solicitacao-aceita e o -rejeitada também vêm com a sua autoria, e esses não são eco: eles são a resposta ao comando, pelo segundo caminho. É por eles que o cliente que perdeu a resposta da chamada — timeout, processo reiniciado — descobre o que aconteceu, correlacionando pelo solicitacaoId que ele próprio cunhou. Todo comando de todo plugin produz um, inclusive os da jornada.
Retry e reenvio
Falha de entrega vira reagendamento com backoff exponencial (de 1 min até 1 h), com teto de 10 tentativas — inclusive quando o nome do seu callback deixa de resolver no DNS. O que esgota o teto fica visível como dead letter e pode ser reenviado à mão — não se perde. O reenvio sai com a mesma X-Geohub-Sequencia de antes.
O endereço é o callback_url do seu cadastro de integrador, o único: todo evento que é seu — de ocorrência, de jornada ou o desfecho de um comando — vai para lá. Por SSRF, ele precisa ser https, na porta 443 (salvo quando a allowlist traz a porta), com o host na allowlist do seu cadastro, e resolver para endereço público.
Erros e códigos
Duas famílias, e confundi-las é o erro de desenho mais comum:
Recusa de protocolo — defeito do integrador. Trate como .
| código | status | o que é |
|---|---|---|
NAO_AUTENTICADO | 401 | token ausente ou inválido |
SEM_CAPACIDADE | 403 | o papel não alcança o verbo |
GUARDA_DE_DOMINIO | 403 | o papel alcança, mas o objeto não é seu, ou faltou a referenciaExterna no registro |
AUTORIA_FORJADA | 403 | o x-autoria não é o da sessão |
CORPO_INVALIDO | 400 | a entrada não cumpre o contrato (com detalhes[] por campo) |
SOLICITACAO_AUSENTE | 400 | comando de integrador sem x-solicitacao-id |
SOLICITACAO_INVALIDA | 400 | o x-solicitacao-id não é um UUID |
SOLICITACAO_EM_ANDAMENTO | 409 | o mesmo x-solicitacao-id ainda executa: repita depois do Retry-After |
SOLICITACAO_REUSADA | 422 | o x-solicitacao-id já foi de outro comando |
VERBO_DESCONHECIDO | 404 | verbo fora da tabela de exposição |
Falha de negócio — resposta normal. Trate na tela.
| código | status | o que é |
|---|---|---|
ocorrencia-inexistente / despacho-inexistente | 404 | o id não existe |
jornada-inexistente / guarnicao-inexistente / pausa-inexistente | 404 | idem |
transicao-invalida | 409 | o verbo não vale no status atual; detalhe diz qual era |
referencia-externa-ja-registrada | 409 | o protocolo já identifica uma ocorrência sua; o id vem em contexto.ocorrenciaId |
jornada-ja-ativa | 409 | o policial já tem jornada não terminal |
viatura-ja-tem-guarnicao | 409 | a viatura já tem guarnição não terminal |
comando-nao-assumido | 409 | prontidão antes de assumirComando |
jornada-nao-iniciada / sem-posto-de-comandante | 409 | o indicado não pode assumir o comando |
| qualquer outro código | 422 | a requisição valia; o domínio recusou |
Decida sempre pelo codigo, nunca pelo texto de mensagem. Quando a falha traz dado para seguir, ele vem em contexto — nunca no texto. No seu código isso vira um tipo: desfecho é valor, recusa é exceção.
/**
* O desfecho de um comando.
*
* Falha de **negócio** é valor, não exceção: "a viatura não tem guarnição
* disponível" é uma resposta que o despachante trata na tela, não um
* incidente. Recusa de **protocolo** (token inválido, papel que não alcança
* o verbo, corpo fora do contrato) continua sendo exceção — ela é defeito
* do integrador, e engoli-la num `if` faria um bug de integração parecer
* uma regra de negócio.
*
* A falha é a do plugin, inteira: discriminada por `codigo`, com `contexto`
* só onde existe. Quem lê `codigo === 'referencia-externa-ja-registrada'`
* tem `contexto.ocorrenciaId` tipado, sem voltar ao `typeof`.
*/
export type Desfecho<T, F extends { readonly codigo: string }> =
| ({ readonly aceito: false } & F)
| { readonly aceito: true; readonly valor: T };// #endregion desfecho
// #endregion portas
export {};Pela REST, a tradução é sua:
/** O envelope de resposta de toda rota do plugin. */
interface RespostaDoPlugin {
readonly dados?: unknown;
readonly erro?: {
readonly codigo?: string;
readonly contexto?: Readonly<Record<string, unknown>>;
readonly mensagem?: string;
};
}
/**
* Os códigos que são recusa de **protocolo** — defeito do integrador, não
* regra de negócio. Eles viram exceção; o resto vira `Desfecho`.
*/
const RECUSAS_DE_PROTOCOLO = new Set([
'AUTORIA_FORJADA',
'CORPO_INVALIDO',
'ERRO_INTERNO',
'NAO_AUTENTICADO',
'SEM_CAPACIDADE',
'SOLICITACAO_AUSENTE',
'SOLICITACAO_EM_ANDAMENTO',
'SOLICITACAO_INVALIDA',
'SOLICITACAO_REUSADA',
'VERBO_DESCONHECIDO',
]);
export { ErroDeProtocolo } from './erro-de-protocolo.js';
/** O erro do fio: texto até ser lido pelo contrato. */
interface ErroNoFio {
readonly codigo: string;
readonly contexto?: Readonly<Record<string, unknown>>;
readonly detalhe: string;
}
/**
* Traduz a resposta HTTP em `Desfecho`, com `ler` dando forma ao `dados` e
* `lerFalha` dando forma à falha pelo contrato do plugin. Um código que o
* contrato não conhece não vira desfecho: é o contrato que divergiu, e isso
* é recusa de protocolo, como as outras.
*
* `GUARDA_DE_DOMINIO` fica de fora das recusas de protocolo de propósito:
* ela é um `403` que diz "esta guarnição não é sua", e o integrador a trata
* como resposta — não como bug.
*/
async function desfechoDe<T, F extends { readonly codigo: string }>(
resposta: Response,
ler: (dados: unknown) => T,
lerFalha: (erro: ErroNoFio) => F | undefined,
): Promise<Desfecho<T, F>> {
const corpo = (await resposta.json()) as RespostaDoPlugin;
if (resposta.ok) return { aceito: true, valor: ler(corpo.dados) };
const codigo = corpo.erro?.codigo ?? 'ERRO_DESCONHECIDO';
const mensagem = corpo.erro?.mensagem ?? `HTTP ${resposta.status}`;
if (RECUSAS_DE_PROTOCOLO.has(codigo)) {
const retryAfter = Number(resposta.headers.get('Retry-After') ?? '');
throw new ErroDeProtocolo(
resposta.status,
codigo,
mensagem,
Number.isInteger(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
);
}
const falha = lerFalha({
codigo,
...(corpo.erro?.contexto === undefined
? {}
: { contexto: corpo.erro.contexto }),
detalhe: mensagem,
});
if (falha === undefined) {
throw new ErroDeProtocolo(
resposta.status,
codigo,
CODIGOS_CONHECIDOS.has(codigo)
? `a falha ${codigo} veio sem o contexto que o contrato promete: ${mensagem}`
: `código fora do contrato do plugin: ${mensagem}`,
);
}
return { aceito: false, ...falha };
}
const CODIGOS_CONHECIDOS = new Set<string>([
...Object.values(CODIGO_DE_FALHA_DE_JORNADA),
...Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA),
]);
/** A falha de jornada: todo código do contrato se explica sozinho. */
const falhaDeJornada = (erro: ErroNoFio): FalhaDeJornada | undefined => {
for (const codigo of Object.values(CODIGO_DE_FALHA_DE_JORNADA)) {
if (codigo === erro.codigo) return { codigo, detalhe: erro.detalhe };
}
return undefined;
};
/** A falha de ocorrência: a referência repetida traz o id da que já existe. */
const falhaDeOcorrencia = (erro: ErroNoFio): FalhaDeOcorrencia | undefined => {
if (
erro.codigo ===
CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
) {
const existente = erro.contexto?.['ocorrenciaId'];
return typeof existente === 'string'
? {
codigo: erro.codigo,
contexto: { ocorrenciaId: ocorrenciaId(existente) },
detalhe: erro.detalhe,
}
: undefined;
}
for (const codigo of Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA)) {
if (
codigo !==
CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA &&
codigo === erro.codigo
) {
return { codigo, detalhe: erro.detalhe };
}
}
return undefined;
};import {
CODIGO_DE_FALHA_DE_JORNADA,
guarnicaoId,
jornadaId,
selarGuarnicaoElegivel,
usuarioId,
viaturaId,
} from '@geocontrol/geohub-sdk/jornada';
import {
CODIGO_DE_FALHA_DE_OCORRENCIA,
despachoId,
ocorrenciaId,
} from '@geocontrol/geohub-sdk/ocorrencia';
import { ErroDeProtocolo } from './erro-de-protocolo.js';
/**
* Os códigos que são recusa de **protocolo** — defeito do integrador, não
* regra de negócio. Eles viram exceção; o resto vira `Desfecho`.
*/
const RECUSAS_DE_PROTOCOLO = new Set([
'AUTORIA_FORJADA',
'CORPO_INVALIDO',
'ERRO_INTERNO',
'NAO_AUTENTICADO',
'SEM_CAPACIDADE',
'SOLICITACAO_AUSENTE',
'SOLICITACAO_EM_ANDAMENTO',
'SOLICITACAO_INVALIDA',
'SOLICITACAO_REUSADA',
'VERBO_DESCONHECIDO',
]);
export { ErroDeProtocolo } from './erro-de-protocolo.js';
/**
* Traduz a resposta HTTP em `Desfecho`, com `ler` dando forma ao `dados` e
* `lerFalha` dando forma à falha pelo contrato do plugin. Um código que o
* contrato não conhece não vira desfecho: é o contrato que divergiu, e isso
* é recusa de protocolo, como as outras.
*
* `GUARDA_DE_DOMINIO` fica de fora das recusas de protocolo de propósito:
* ela é um `403` que diz "esta guarnição não é sua", e o integrador a trata
* como resposta — não como bug.
*/
async function desfechoDe(resposta, ler, lerFalha) {
const corpo = await resposta.json();
if (resposta.ok) return { aceito: true, valor: ler(corpo.dados) };
const codigo = corpo.erro?.codigo ?? 'ERRO_DESCONHECIDO';
const mensagem = corpo.erro?.mensagem ?? `HTTP ${resposta.status}`;
if (RECUSAS_DE_PROTOCOLO.has(codigo)) {
const retryAfter = Number(resposta.headers.get('Retry-After') ?? '');
throw new ErroDeProtocolo(
resposta.status,
codigo,
mensagem,
Number.isInteger(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
);
}
const falha = lerFalha({
codigo,
...(corpo.erro?.contexto === undefined
? {}
: { contexto: corpo.erro.contexto }),
detalhe: mensagem,
});
if (falha === undefined) {
throw new ErroDeProtocolo(
resposta.status,
codigo,
CODIGOS_CONHECIDOS.has(codigo)
? `a falha ${codigo} veio sem o contexto que o contrato promete: ${mensagem}`
: `código fora do contrato do plugin: ${mensagem}`,
);
}
return { aceito: false, ...falha };
}
const CODIGOS_CONHECIDOS = new Set([
...Object.values(CODIGO_DE_FALHA_DE_JORNADA),
...Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA),
]);
/** A falha de jornada: todo código do contrato se explica sozinho. */
const falhaDeJornada = (erro) => {
for (const codigo of Object.values(CODIGO_DE_FALHA_DE_JORNADA)) {
if (codigo === erro.codigo) return { codigo, detalhe: erro.detalhe };
}
return undefined;
};
/** A falha de ocorrência: a referência repetida traz o id da que já existe. */
const falhaDeOcorrencia = (erro) => {
if (
erro.codigo ===
CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
) {
const existente = erro.contexto?.['ocorrenciaId'];
return typeof existente === 'string'
? {
codigo: erro.codigo,
contexto: { ocorrenciaId: ocorrenciaId(existente) },
detalhe: erro.detalhe,
}
: undefined;
}
for (const codigo of Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA)) {
if (
codigo !==
CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA &&
codigo === erro.codigo
) {
return { codigo, detalhe: erro.detalhe };
}
}
return undefined;
};
// #endregion desfecho-rest
const nada = () => undefined;
const texto = (dados) => String(dados);
/**
* O selo de elegibilidade é do Geohub: a rota só devolve guarnição
* `disponivel`, então selar aqui é registrar que foi ele quem conferiu.
*/
const guarnicaoElegivel = (dados) => {
if (dados === null || dados === undefined) return undefined;
const lida = dados;
return selarGuarnicaoElegivel({
composicao: lida.composicao.map((i) => ({
posto: i.posto,
usuario: usuarioId(i.usuario),
})),
guarnicaoId: guarnicaoId(lida.guarnicaoId),
viatura: viaturaId(lida.viatura),
});
};
const url = (partes, ...valores) =>
partes.reduce(
(acumulado, parte, indice) =>
acumulado +
parte +
(indice < valores.length ? encodeURIComponent(valores[indice]) : ''),
'',
);
// #region jornada-rest
/** O plugin `jornada` pelos caminhos da OpenAPI. */
export function jornadaPorRest(sessao) {
return {
assumirComando: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar(
'POST',
url`/guarnicoes/${dados.guarnicaoId}/comando`,
{ corpo: { jornadaId: dados.jornadaId }, solicitacaoId },
),
nada,
falhaDeJornada,
),
),
constituirGuarnicao: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', '/guarnicoes', {
corpo: dados,
solicitacaoId,
}),
(id) => guarnicaoId(texto(id)),
falhaDeJornada,
),
),
declararProntidao: (id) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', url`/guarnicoes/${id}/prontidao`, {
solicitacaoId,
}),
nada,
falhaDeJornada,
),
),
// Leitura não leva `solicitacaoId`: consultar não é agir, e repetir um
// `GET` não muda nada.
guarnicaoDisponivelNaViatura: async (viatura) => {
const resposta = await sessao.requisitar(
'GET',
url`/viaturas/${viatura}/guarnicao-disponivel`,
);
const desfecho = await desfechoDe(
resposta,
guarnicaoElegivel,
falhaDeJornada,
);
return desfecho.aceito ? desfecho.valor : undefined;
},
iniciarJornada: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', url`/jornadas/${dados.id}/inicio`, {
corpo: { posto: dados.posto },
solicitacaoId,
}),
nada,
falhaDeJornada,
),
),
registrarApresentacao: (usuario) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar(
'POST',
url`/policiais/${usuario}/apresentacao`,
{ solicitacaoId },
),
(id) => jornadaId(texto(id)),
falhaDeJornada,
),
),
};
}
// #endregion jornada-rest
// #region ocorrencia-rest
/** O plugin `ocorrencia` pelos caminhos da OpenAPI. */
export function ocorrenciaPorRest(sessao) {
return {
atualizarOcorrencia: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('PATCH', url`/ocorrencias/${dados.id}`, {
corpo: { alteracoes: dados.alteracoes },
solicitacaoId,
}),
nada,
falhaDeOcorrencia,
),
),
despachar: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar(
'POST',
url`/ocorrencias/${dados.ocorrenciaId}/despachos`,
{
corpo: {
...(dados.instrucoes === undefined
? {}
: { instrucoes: dados.instrucoes }),
recurso: dados.recurso,
},
solicitacaoId,
},
),
(id) => despachoId(texto(id)),
falhaDeOcorrencia,
),
),
registrarOcorrencia: (dados) =>
sessao.comando(async (solicitacaoId) =>
desfechoDe(
await sessao.requisitar('POST', '/ocorrencias', {
corpo: dados,
solicitacaoId,
}),
(id) => ocorrenciaId(texto(id)),
falhaDeOcorrencia,
),
),
};
}
// #endregion ocorrencia-restPelo SDK, a falha de negócio já chega como Result e a recusa de protocolo já é exceção — só resta adaptar ao seu vocabulário:
/**
* O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
* dado, a falha leva a falha inteira — o `codigo` discriminado e o
* `contexto` de quem o tem.
*/
const comoDesfecho = <T, F extends { readonly codigo: string }>(
resultado: Result<T, F>,
): Desfecho<T, F> =>
resultado.success
? { aceito: true, valor: resultado.data }
: { aceito: false, ...resultado.error };/**
* O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
* dado, a falha leva a falha inteira — o `codigo` discriminado e o
* `contexto` de quem o tem.
*/
const comoDesfecho = (resultado) =>
resultado.success
? { aceito: true, valor: resultado.data }
: { aceito: false, ...resultado.error };O roteiro inteiro
É este o caso que o pacote de exemplo roda — duas vezes, pelo SDK e pela REST, sem um caso editado.
Rodar o exemplo
pnpm --filter @geocontrol/exemplo-integrador-despacho testA suíte sobe o Geohub em processo — os dois plugins com a REST gerada, os
em memória, o outbox e um emissor de webhook de verdade apontado para oReceptorDeEventos. Não precisa de Docker e não precisa de ambiente.