Files
DeskcommCRM/app/api/v1/agenda/agendamentos/_handler.ts
T
Pessoaand423313 f652cf5e64 fix(agenda): o gatilho de automação leva o tipo de atendimento real
Reconciliação do PR #784 (`feat/agenda-emite-eventos`). Os quatro gatilhos, a
fonte única de `ENTIDADE_ESPERADA_POR_GATILHO` e o contexto de compromisso no
motor são acertos e ficam. O que não pode entrar é o literal.

O DEFEITO, confirmado no diff do PR:

  _handler.ts:373 (alterar)   nomeDoTipo: "Agendamento",
  _handler.ts:430 (cancelar)  nomeDoTipo: "Agendamento",
  _handler.ts:218 (marcar)    nomeDoTipo: tipo.name,     ← o único certo

`fecharOLaco` manda esse valor em `payload.event_type_name`, e o editor de
regras (`RuleEditor.tsx`, `AGENDAMENTO_FIELDS`) oferece justamente a condição
"Tipo de atendimento contém …" lendo `event.event_type_name` — é a ÚNICA
condição por onde uma regra distingue "Limpeza de pele" de "Avaliação", porque
a linha do compromisso só guarda o uuid `event_type_id`.

Com o literal, três dos quatro gatilhos (`confirmed`, `rescheduled`,
`cancelled`) anunciam sempre a mesma palavra. A regra aparece no seletor, o
operador a salva, o horário é confirmado — e nada roda, porque "Agendamento"
não contém "Limpeza". É a classe "a tela oferece o que o código ignora":
controle decorativo é pior que controle ausente, porque a pessoa acredita que
configurou. E o PR não tinha como pegar: `laco.gatilho.test.ts` cobre a função
pura (QUAL gatilho), nunca o payload.

O CONSERTO:
- `nomeDoTipoDoCompromisso()` lê `calendar_event_types.name` pelo
  `event_type_id` da linha; `cancelar` passa a selecionar essa coluna.
  `marcar` segue usando `tipo.name`, que já tem em mãos.
- `NOME_GENERICO_DO_TIPO` sai de `lib/agenda/tipos.ts` e é explicitamente
  FALLBACK: só alcançado quando o tipo foi apagado depois do compromisso.
  Perder o catálogo não pode desfazer um cancelamento já gravado.
- `tests/unit/agenda-gatilho-leva-o-tipo-real.test.ts` mede os QUATRO gatilhos
  na linha que chega ao `event_log` — não na chamada — porque as irmãs não se
  parecem por fora: `marcar` estava certo e serve de controle de vacuidade.
  O quinto caso (tipo apagado) prova que o nome vem da leitura.

Medido: `npx vitest run tests/unit/agenda-gatilho-leva-o-tipo-real.test.ts` →
5/5 verdes. As 16 suítes vizinhas de agenda/automação: 129/129 verdes.
`pnpm typecheck` exit 0.

Co-authored-by: 423313 <423313@users.noreply.github.com>
2026-09-14 10:17:07 -03:00

726 lines
30 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { Json } from "@/lib/database.types";
/**
* A REGRA de marcar, remarcar e cancelar — fora da rota, de propósito.
*
* ⚠️ UMA FERRAMENTA MCP NÃO CHAMA ROTA NEXT. Não há `request`, não há cookie, e
* a rota devolve `Response` em vez de dado. Por isso este repo tem o padrão do
* `_handler` — messages, conversations, contacts, leads e pipelines já o usam: a
* regra mora aqui, e a ROTA e a TOOL chamam a mesma função. A agenda era a
* exceção, com a regra inline na rota, e por isso as três ferramentas de escrita
* do agente não tinham o que embrulhar.
*
* ⚠️ A ORGANIZAÇÃO ENTRA POR PARÂMETRO (`ctx.organization_id`), nunca resolvida
* aqui. Quem chama é que sabe de onde ela vem: a rota tira do cookie validado, a
* tool tira do contexto do agente. Se este arquivo lesse cookie, deixaria de
* servir à tool — que é o motivo de ele existir.
*
* ⚠️ E O `organization_id` VAI EM TODA QUERY. Pelo MCP o client é service-role e
* a RLS não vale: sem o filtro explícito, a leitura entregaria ao modelo
* compromisso de outra organização — e ler não devolve erro, então nada
* quebraria; o agente só passaria a "saber" coisas que não são da casa dele.
*
* A recusa sai como `ApiError`: a rota a traduz em `fail()`, a tool a traduz
* para o modelo, e nenhum dos dois reimplementa a decisão.
*/
import { horariosLivresDaOrg } from "@/lib/agenda/consulta";
import {
atividadeDaTransicao,
autorParaTimeline,
gatilhoDaTransicao,
type SituacaoAnterior,
type Transicao,
} from "@/lib/agenda/laco";
import {
ALVO_DE_VINCULO_DO_AGENDAMENTO,
ENTIDADE_DO_AGENDAMENTO,
NOME_GENERICO_DO_TIPO,
VINCULO_DE_AGENDAMENTO,
} from "@/lib/agenda/tipos";
import { ApiError } from "@/lib/api/types";
import type { Actor, HandlerCtx } from "@/lib/api/handlers/types";
import { audit } from "@/lib/audit";
import { resolveActiveLeadForContact, type LeadCandidate } from "@/lib/leads/active-lead";
import { emitLeadActivity } from "@/lib/leads/activity-emitter";
import { registraFalhaDeAtividade } from "@/lib/leads/activity-write-failure";
import { moverLeadParaEtapaDeAgendamento } from "@/lib/leads/appointment-stage-move";
import { logger } from "@/lib/logger";
import type { SupabaseClient } from "@supabase/supabase-js";
type SB = SupabaseClient;
/** A recusa da coleta vira código de wire — um mapa, não `if` espalhado. */
const CODIGO_DA_RECUSA = {
tipo_desconhecido: { status: 404, code: "not_found" },
tipo_desativado: { status: 422, code: "agenda_tipo_desativado" },
sem_responsavel: { status: 422, code: "agenda_sem_responsavel" },
jornada_mal_configurada: { status: 422, code: "agenda_disponibilidade_invalida" },
erro_interno: { status: 500, code: "internal_error" },
} as const;
export interface MarcarInput {
event_type_id: string;
starts_at: string;
owner_user_id?: string;
contact_id?: string;
conversation_id?: string;
title?: string;
notes?: string;
/**
* Convidado externo, digitado na tela. `""` limpa; ausente não mexe.
*
* NÃO é `contact_id`, e a distinção é o motivo de a coluna existir: o contato
* é quem recebe o atendimento, e quem precisa entrar na sala pode ser outra
* pessoa. Quem transforma isto em convite do Google é o worker de push.
*/
guest_email?: string;
}
export interface AlterarInput {
id: string;
revision?: number;
outcome_message_id?: string;
confirmation_next_at?: string;
starts_at?: string;
status?: "confirmed" | "completed" | "no_show";
notes?: string;
/** Igual ao de `MarcarInput`: `""` desconvida, ausente não mexe. */
guest_email?: string;
}
export interface CancelarInput {
id: string;
revision?: number;
reason: string;
}
export async function marcarAgendamentoHandler(
supabase: SB,
ctx: HandlerCtx,
input: MarcarInput,
): Promise<Record<string, unknown>> {
const inicio = new Date(input.starts_at);
const { data: tipo, error: erroTipo } = await supabase
.from("calendar_event_types")
.select(
"id, name, is_active, duration_minutes, default_owner_user_id, requires_confirmation, location_kind, location_details",
)
.eq("organization_id", ctx.organization_id)
.eq("id", input.event_type_id)
.maybeSingle();
if (erroTipo) throw new ApiError(500, "internal_error", undefined, ctx.requestId, erroTipo.message);
if (!tipo) {
throw new ApiError(404, "not_found", undefined, ctx.requestId, "Tipo de agendamento não encontrado.");
}
if (!tipo.is_active) {
throw new ApiError(422, "agenda_tipo_desativado", undefined, ctx.requestId, `"${tipo.name}" está desativado.`);
}
const donoId = input.owner_user_id ?? tipo.default_owner_user_id;
if (!donoId) {
throw new ApiError(
422,
"agenda_sem_responsavel",
undefined,
ctx.requestId,
`"${tipo.name}" não tem responsável definido, e sem responsável não há agenda.`,
);
}
// O `contact_id` É INPUT EXTERNO E PRECISA SER RESOLVIDO, não repassado.
//
// ⚠️ Ele atravessava a borda cru: `lib/mcp/tools/agendamento.ts:259` aceita
// `z.string().uuid()` livre do modelo, e o INSERT abaixo o gravava sem
// perguntar de quem é. Este handler roda com service role e filtra
// `organization_id` em toda query — `contact_id` era o ÚNICO campo de entrada
// que não era resolvido. Pela rota HTTP bastava um `agent` da org A.
//
// Hoje não vaza PII (a tela lê contatos com a sessão do usuário, sob RLS, e
// volta nulo) e não permite enumerar (o par 201/404 só confirma um uuid que
// quem chamou já tem). O que preocupa é o DEPOIS: o cabeçalho da migration
// 0177 diz que `contact_id` é "quem recebe o LEMBRETE". No dia em que o worker
// de lembrete nascer, esta linha vira a organização A mandando WhatsApp para o
// cliente da B — e `on delete restrict` faz a linha ficar presa numa org que
// não a enxerga nem consegue soltá-la.
//
// O molde é o de `app/api/v1/messages/_handler.ts:333` — resolver contra a org
// e recusar com 404, sem dizer se o id existe noutro lugar.
if (input.contact_id) {
const { data: contato, error: erroContato } = await supabase
.from("contacts")
.select("id")
.eq("id", input.contact_id)
.eq("organization_id", ctx.organization_id)
.maybeSingle();
if (erroContato) {
throw new ApiError(500, "internal_error", undefined, ctx.requestId, erroContato.message);
}
if (!contato) {
throw new ApiError(404, "not_found", undefined, ctx.requestId, "Contato não encontrado.");
}
}
const fim = new Date(inicio.getTime() + tipo.duration_minutes * 60_000);
const consulta = await exigeHorarioLivre(supabase, ctx, {
eventTypeId: tipo.id,
donoId,
inicio,
fim,
});
const booking = tipo.location_kind === "google_meet" ? ctx.meetingBooking : undefined;
if (booking && (booking.boundary.organization_id !== ctx.organization_id || booking.boundary.contact_id !== input.contact_id || ctx.actor.type !== "ai_agent")) {
throw new ApiError(403,"forbidden",undefined,ctx.requestId,"A conversa deste atendimento mudou.");
}
const delivery = booking ? { state:"waiting_for_link",generation:randomUUID(),service_boundary:booking.boundary,source_operation_id:booking.sourceJobId,
booking_claim:booking.claim,authorized_by:{kind:ctx.actor.type,id:ctx.actor.id} } : {state:"none"};
const { data: criado, error: erroInsert } = await supabase
.from("calendar_appointments")
.insert({
organization_id: ctx.organization_id,
event_type_id: tipo.id,
title: input.title ?? tipo.name,
starts_at: inicio.toISOString(),
ends_at: fim.toISOString(),
// O fuso do compromisso é campo de primeira classe: é o da JORNADA, onde
// o horário foi decidido, e ele viaja até o lembrete (ACHADO 09).
time_zone: consulta.fusoDaRegra,
status: tipo.requires_confirmation ? "pending" : "confirmed",
owner_user_id: donoId,
contact_id: input.contact_id ?? null,
conversation_id: booking?.boundary.conversation_id ?? input.conversation_id ?? null,
meeting_delivery: delivery as unknown as Json,
location_kind: tipo.location_kind,
location_details: tipo.location_details,
notes: input.notes ?? null,
// `|| null` e não `?? null`: a rota deixa passar `""` (o campo limpo na
// tela), e string vazia gravada seria um convidado sem e-mail — que faz o
// Google recusar o EVENTO INTEIRO, não só o convidado.
guest_email: input.guest_email || null,
created_by_kind: autorParaCriacao(ctx.actor),
created_by_user_id: ctx.actor.type === "user" ? ctx.actor.id : null,
source: ctx.actor.type === "user" ? "ui" : "mcp",
})
.select("id, starts_at, ends_at, status, time_zone, revision, meeting_state, meeting_url")
.single();
if (erroInsert) {
throw new ApiError(500, "internal_error", undefined, ctx.requestId, erroInsert.message);
}
const transicao: Transicao = criado.status === "pending" ? "pending" : "confirmed";
await fecharOLaco(supabase, ctx, {
appointmentId: criado.id,
contactId: input.contact_id ?? null,
atividade: atividadeDaTransicao(null, transicao),
gatilho: gatilhoDaTransicao(null, transicao),
transicao,
fusoDoCompromisso: criado.time_zone,
nomeDoTipo: tipo.name,
});
void audit({
action: "agenda.appointment_created",
actorUserId: ctx.actor.type === "user" ? ctx.actor.id : null,
organizationId: ctx.organization_id,
resourceType: "calendar_appointment",
resourceId: criado.id,
requestId: ctx.requestId,
metadata: { event_type_id: tipo.id, owner_user_id: donoId, time_zone: criado.time_zone },
});
return criado as Record<string, unknown>;
}
/**
* ⚠️ REMARCAR NÃO É CANCELAR MAIS CRIAR — é a MESMA linha mudando de horário.
*
* 1. A TIMELINE conta a história certa. Cancelar+criar emitiria
* `appointment_cancelled` seguido de `appointment_scheduled`: duas linhas
* dizendo que o cliente desistiu e voltou, quando ele só mudou de horário.
* 2. O ESPELHO NO GOOGLE é atualizado, não destruído e refeito — recriar exigiria
* casar o evento antigo lá fora, e casar por janela de horário erra nos dois
* sentidos (barrado até haver identificador próprio no espelho).
* 3. O `id` que o cliente já recebeu continua valendo.
*
* `rescheduled_from_id` fica VAZIO: ele é do fluxo em que a remarcação gera
* compromisso NOVO (auto-agendamento), que não existe. Usá-lo aqui seria
* inventar encadeamento onde há uma linha só.
*/
export async function alterarAgendamentoHandler(
supabase: SB,
ctx: HandlerCtx,
input: AlterarInput,
): Promise<Record<string, unknown>> {
const atual = await exigeAgendamento(supabase, ctx, input.id, [
"id",
"revision",
"event_type_id",
"owner_user_id",
"contact_id",
"starts_at",
"status",
"time_zone",
]);
if (input.revision !== undefined && input.revision !== Number(atual.revision)) throw new ApiError(409,"conflict",undefined,ctx.requestId,"O compromisso mudou. Recarregue antes de confirmar.");
if (atual.status === "cancelled") {
throw new ApiError(
422,
"agenda_ja_cancelado",
undefined,
ctx.requestId,
"Este agendamento foi cancelado. Marque um novo em vez de reabrir este.",
);
}
const mudanca: Record<string, unknown> = {};
if (input.status === "completed" || input.status === "no_show") {
if (ctx.actor.type !== "user") throw new ApiError(403,"forbidden",undefined,ctx.requestId,"Peça à equipe para confirmar a presença no compromisso. Uma interpretação de texto não registra o fato.");
if (input.outcome_message_id) mudanca.outcome_message_id=input.outcome_message_id;
}
if(input.confirmation_next_at) mudanca.confirmation_next_at=input.confirmation_next_at;
if (input.notes !== undefined) mudanca.notes = input.notes;
// Trocar SÓ o convidado não é remarcação nem mudança de situação, então não
// produz `transicao` — e não deveria: a timeline do lead não ganha notícia
// por causa de um e-mail digitado. Quem leva a mudança ao Google é a coluna
// gerada `needs_google_push` (migration 0225), que compara a revisão
// publicável com o último aceite; notes e metadata não criam intenção.
if (input.guest_email !== undefined) mudanca.guest_email = input.guest_email || null;
let transicao: Transicao | null = null;
if (input.starts_at) {
const novoInicio = new Date(input.starts_at);
const { data: tipo } = await supabase
.from("calendar_event_types")
.select("id, duration_minutes")
.eq("organization_id", ctx.organization_id)
.eq("id", (atual.event_type_id as string | null) ?? "")
.maybeSingle();
if (!tipo) {
throw new ApiError(404, "not_found", undefined, ctx.requestId, "O tipo deste agendamento não existe mais.");
}
const novoFim = new Date(novoInicio.getTime() + tipo.duration_minutes * 60_000);
// ⚠️ O PRÓPRIO COMPROMISSO OCUPA O HORÁRIO DELE. Remarcar para o mesmo
// instante é no-op — sem esta guarda ele se veria como conflito e recusaria
// a si mesmo.
const mesmoHorario = new Date(atual.starts_at as string).getTime() === novoInicio.getTime();
if (!mesmoHorario) {
const consulta = await exigeHorarioLivre(supabase, ctx, {
eventTypeId: tipo.id,
donoId: atual.owner_user_id as string,
inicio: novoInicio,
fim: novoFim,
});
mudanca.starts_at = novoInicio.toISOString();
mudanca.ends_at = novoFim.toISOString();
mudanca.time_zone = consulta.fusoDaRegra;
transicao = "rescheduled";
}
}
if (input.status && input.status !== atual.status) {
// ⚠️ DESFECHO É SOBRE O PASSADO. `completed` e `no_show` respondem "o que
// aconteceu?", e num compromisso que ainda não começou não aconteceu nada.
//
// Isto não era guardado, e o buraco ficou barato enquanto só gente marcava
// pela tela — os botões Realizado/Faltou vivem no histórico. Deixa de ser
// barato agora que `crm_set_appointment_outcome` põe a mesma escrita na mão
// de um modelo, que decide por texto e não por onde clicou.
//
// O dano do `no_show` prematuro é concreto e não é só um registro errado:
// `no_show` está em `LIBERAM_O_HORARIO` (`lib/agenda/ocupados.ts`), então
// ele DEVOLVE ao pool um horário que o cliente ainda espera. Outro cliente
// pega, e os dois aparecem na mesma hora.
//
// O `completed` prematuro tem outro dano: grava `appointment_completed` na
// timeline e some com os botões da tela, tirando de quem atendeu a chance de
// registrar o que de fato aconteceu.
//
// A guarda é AQUI, no handler, e não na ferramenta: a regra não é sobre quem
// chama. Recusa de negócio com o código do repo — a tool a traduz em resposta
// ao modelo, sem derrubar o turno.
if (
(input.status === "completed" || input.status === "no_show") &&
new Date(atual.starts_at as string).getTime() > Date.now()
) {
throw new ApiError(
422,
"agenda_ainda_nao_aconteceu",
undefined,
ctx.requestId,
"Este compromisso ainda não começou — não dá para registrar se a pessoa veio ou faltou. " +
"Se ela avisou que não vem, desmarque em vez de registrar falta.",
);
}
mudanca.status = input.status;
// Remarcar vence: se vieram os dois, a notícia da timeline é a remarcação.
transicao = transicao ?? input.status;
}
if (Object.keys(mudanca).length === 0) return { id: atual.id, inalterado: true };
const salvo = await alteraComRevisao(supabase,ctx,input.id,input.revision ?? Number(atual.revision),mudanca);
if (transicao) {
await fecharOLaco(supabase, ctx, {
appointmentId: atual.id as string,
contactId: (atual.contact_id as string | null) ?? null,
atividade: atividadeDaTransicao(atual.status as SituacaoAnterior, transicao),
gatilho: gatilhoDaTransicao(atual.status as SituacaoAnterior, transicao),
transicao,
fusoDoCompromisso: String(salvo.time_zone),
nomeDoTipo: await nomeDoTipoDoCompromisso(supabase, ctx, atual.event_type_id as string | null),
outcome: {revision:salvo.revision,source_kind:salvo.outcome_source_kind,message_id:salvo.outcome_message_id,recorded_at:salvo.outcome_recorded_at},
});
void audit({action: transicao === "rescheduled" ? "agenda.appointment_rescheduled" : transicao === "completed" || transicao === "no_show" ? "agenda.appointment_outcome_recorded" : "agenda.appointment_updated",
actorUserId:ctx.actor.type === "user" ? ctx.actor.id : null,organizationId:ctx.organization_id,
resourceType:"calendar_appointment",resourceId:input.id,requestId:ctx.requestId,
metadata:{status:salvo.status,revision:salvo.revision,outcome_source_kind:salvo.outcome_source_kind,outcome_message_id:salvo.outcome_message_id}});
}
if (!transicao) void audit({action:"agenda.appointment_updated",actorUserId:ctx.actor.type==="user"?ctx.actor.id:null,
organizationId:ctx.organization_id,resourceType:"calendar_appointment",resourceId:input.id,requestId:ctx.requestId,
metadata:{revision:salvo.revision,confirmation_next_at:salvo.confirmation_next_at}});
return salvo as Record<string, unknown>;
}
/**
* Cancela de verdade (status), não apaga a linha: o histórico do que foi marcado
* e desmarcado é o que deixa o Radar distinguir lead que desistiu de lead que
* nunca marcou, e o agente não reoferecer o horário que a pessoa recusou.
*
* ⚠️ O MOTIVO É OBRIGATÓRIO — é o que a equipe lê ao ver o horário vago. Sem ele,
* alguém liga para o cliente perguntando o que houve, ou não liga e o lead esfria
* sem ninguém saber por quê.
*/
export async function cancelarAgendamentoHandler(
supabase: SB,
ctx: HandlerCtx,
input: CancelarInput,
): Promise<Record<string, unknown>> {
const atual = await exigeAgendamento(supabase, ctx, input.id, [
"id",
"revision",
"contact_id",
"event_type_id",
"status",
"time_zone",
]);
// Idempotente: cancelar o que já está cancelado devolve o estado, não erro —
// quem chamou queria o compromisso desmarcado, e ele está.
if (input.revision !== undefined && input.revision !== Number(atual.revision)) throw new ApiError(409,"conflict",undefined,ctx.requestId,"O compromisso mudou. Recarregue antes de confirmar.");
if (atual.status === "cancelled") {
return { id: atual.id, status: "cancelled", ja_estava: true };
}
const salvo = await alteraComRevisao(supabase,ctx,input.id,input.revision ?? Number(atual.revision),{
status:"cancelled",cancellation_reason:input.reason,
});
await fecharOLaco(supabase, ctx, {
appointmentId: atual.id as string,
contactId: (atual.contact_id as string | null) ?? null,
atividade: atividadeDaTransicao(atual.status as SituacaoAnterior, "cancelled"),
gatilho: gatilhoDaTransicao(atual.status as SituacaoAnterior, "cancelled"),
transicao: "cancelled",
fusoDoCompromisso: atual.time_zone as string,
nomeDoTipo: await nomeDoTipoDoCompromisso(supabase, ctx, atual.event_type_id as string | null),
});
void audit({
action: "agenda.appointment_cancelled",
actorUserId: ctx.actor.type === "user" ? ctx.actor.id : null,
organizationId: ctx.organization_id,
resourceType: "calendar_appointment",
resourceId: atual.id as string,
requestId: ctx.requestId,
metadata: { reason: input.reason },
});
return salvo as Record<string, unknown>;
}
/** O compromisso, ou 404 — sempre com o filtro de organização. */
/**
* O NOME DO TIPO DE ATENDIMENTO — lido da linha, nunca digitado aqui.
*
* Ele viaja no payload do gatilho de automação (`event.event_type_name`) e é o
* ÚNICO campo por onde uma regra distingue "Limpeza" de "Avaliação": a linha do
* compromisso guarda `event_type_id`, um uuid que ninguém digita numa condição.
* O editor de regras oferece exatamente essa condição ("Tipo de atendimento
* contém …").
*
* ⚠️ ISTO JÁ FOI UM LITERAL, e o literal é o defeito. `alterar` e `cancelar`
* passavam `"Agendamento"` cravado, então três dos quatro gatilhos
* (`confirmed`, `rescheduled`, `cancelled`) emitiam sempre a mesma palavra —
* a condição aparecia na tela, o operador a salvava, e ela não casava nunca.
* Controle decorativo é pior que controle ausente: a pessoa acredita que
* configurou.
*
* Uma consulta a mais por transição, e só quando há transição. `marcar` não
* chama esta função porque já tem a linha do tipo em mãos.
*/
async function nomeDoTipoDoCompromisso(
supabase: SB,
ctx: HandlerCtx,
eventTypeId: string | null,
): Promise<string> {
if (!eventTypeId) return NOME_GENERICO_DO_TIPO;
const { data } = await supabase
.from("calendar_event_types")
.select("name")
.eq("organization_id", ctx.organization_id)
.eq("id", eventTypeId)
.maybeSingle();
const nome = (data as { name?: string | null } | null)?.name;
// O tipo apagado depois do compromisso é o único caminho até aqui. Falhar a
// leitura NÃO pode desfazer um cancelamento já gravado.
return nome?.trim() ? nome : NOME_GENERICO_DO_TIPO;
}
async function exigeAgendamento(
supabase: SB,
ctx: HandlerCtx,
id: string,
colunas: string[],
): Promise<Record<string, unknown>> {
const { data, error } = await supabase
.from("calendar_appointments")
.select(colunas.join(", "))
.eq("organization_id", ctx.organization_id)
.eq("id", id)
.maybeSingle();
if (error) throw new ApiError(500, "internal_error", undefined, ctx.requestId, error.message);
if (!data) {
throw new ApiError(404, "not_found", undefined, ctx.requestId, "Agendamento não encontrado.");
}
return data as unknown as Record<string, unknown>;
}
/**
* O horário pedido está entre os que esta agenda oferece?
*
* ⚠️ Pela MESMA coleta que responde o GET e as ferramentas de leitura
* (`horariosLivresDaOrg`), nunca por uma segunda. Duas coletas divergem no
* primeiro ajuste: se a regra do que OCUPA mudar, uma muda e a outra não — e aí
* a tela oferece horário que a escrita recusa, ou a escrita aceita um que a tela
* não ofereceu e alguém chega numa hora que já tinha dono.
*/
async function exigeHorarioLivre(
supabase: SB,
ctx: HandlerCtx,
args: { eventTypeId: string; donoId: string; inicio: Date; fim: Date },
): Promise<{ fusoDaRegra: string }> {
const consulta = await horariosLivresDaOrg(supabase, ctx.organization_id, {
eventTypeId: args.eventTypeId,
ownerUserId: args.donoId,
de: args.inicio,
ate: args.fim,
agora: new Date(),
});
if (!consulta.ok) {
const { status, code } = CODIGO_DA_RECUSA[consulta.codigo];
throw new ApiError(status, code, undefined, ctx.requestId, consulta.motivoParaOperador);
}
if (!consulta.publicouHorarios) {
throw new ApiError(
422,
"agenda_fora_da_jornada",
undefined,
ctx.requestId,
"Este responsável ainda não publicou horários de atendimento.",
);
}
if (!consulta.slots.some((s) => s.inicio.getTime() === args.inicio.getTime())) {
throw new ApiError(
422,
"agenda_horario_indisponivel",
undefined,
ctx.requestId,
"Este horário não está disponível. Consulte os horários livres e escolha outro.",
);
}
return { fusoDaRegra: consulta.fusoDaRegra };
}
/** `Actor` → o vocabulário de `calendar_appointments.created_by_kind`. */
function autorParaCriacao(actor: Actor): string {
return actor.type === "user" ? "user" : "ai";
}
/**
* Vínculo e atividade no mesmo fluxo da mutação.
*
* `crm_lead_links` faz o compromisso PERTENCER ao negócio (é por ele que o
* dossiê o lista); `crm_lead_activities` aparece na timeline. A pendência Google
* vem da revisão publicável persistida. Só o vínculo e nada aparece na tela; só
* a atividade e o dossiê não acha o compromisso.
*
* ⚠️ `crm_lead_activities.lead_id` é NOT NULL: agendamento de contato que ainda
* não virou lead não tem onde ancorar, e o rastro vira `event_log` por
* `registraFalhaDeAtividade` em vez de sumir. Não se inventa um terceiro caminho.
*/
async function fecharOLaco(
supabase: SB,
ctx: HandlerCtx,
args: {
appointmentId: string;
contactId: string | null;
atividade: string | null;
/** Gatilho de automação, ou `null` quando a transição não é notícia para uma regra. */
gatilho: string | null;
transicao: Transicao;
fusoDoCompromisso: string;
nomeDoTipo: string;
outcome?: Record<string,unknown>;
},
): Promise<void> {
// Pendência Google é derivada da revisão publicável; não emite evento sem consumer.
// O gatilho de automação, ANTES de qualquer early-return. Ele não depende de
// haver negócio aberto: uma regra de "avise a cliente que confirmou" vale
// igual para quem não tem lead nenhum — e todo o resto desta função é sobre a
// timeline do lead, que é outra pergunta.
//
// Fire-and-forget, como a atividade: falhar em emitir NÃO pode desfazer um
// compromisso que já está gravado. O consumidor é o motor de regras
// (`lib/automation/engine.ts`), que casa por `trigger_event`.
if (args.gatilho) {
const { error } = await supabase.from("event_log").insert({
organization_id: ctx.organization_id,
event_type: args.gatilho,
entity_kind: ENTIDADE_DO_AGENDAMENTO,
entity_id: args.appointmentId,
payload: {
appointment_id: args.appointmentId,
contact_id: args.contactId,
event_type_name: args.nomeDoTipo,
time_zone: args.fusoDoCompromisso,
transicao: args.transicao,
},
// `request_id` sem o prefixo `rule:` de propósito: ele correlaciona com o
// audit log e NÃO aciona o anti-loop do motor, que só barra o que uma
// regra causou.
metadata: { request_id: ctx.requestId },
});
if (error) {
logger.error("[agenda] gatilho de automação não foi emitido", {
appointment_id: args.appointmentId,
organization_id: ctx.organization_id,
gatilho: args.gatilho,
error: error.message,
});
}
}
const leadId = args.contactId ? await leadAtivoDoContato(supabase, ctx, args.contactId) : null;
// ⚠️ ANTES do early-return de `!args.atividade`. Confirmar um agendamento
// pendente é `atividade: null` (nada novo pra timeline — `atividadeDaTransicao`
// já contou "foi marcado" quando ele nasceu), mas é EXATAMENTE a transição que
// move o card de "Agendamento solicitado" pra "Agendado". Um early-return
// antes disto pularia o mirror no caso que mais importa para ele.
if (leadId) {
await moverLeadParaEtapaDeAgendamento(supabase, {
organizationId: ctx.organization_id,
leadId,
transicao: args.transicao,
}).catch((err) => {
logger.error("[agenda] mirror de estágio falhou", {
lead_id: leadId,
organization_id: ctx.organization_id,
transicao: args.transicao,
error: err instanceof Error ? err.message : String(err),
});
});
}
if (!args.atividade) return;
if (!leadId) {
if (args.contactId) {
await registraFalhaDeAtividade(supabase, {
organizationId: ctx.organization_id,
// Sem negócio não há âncora; o contato é o que se sabe, e vai no lugar
// do id para o alerta não sair mudo sobre QUEM ficou sem rastro.
leadId: args.contactId,
tipo: args.atividade,
origem: "agenda (sem negócio aberto para ancorar)",
erro: undefined,
});
}
return;
}
await supabase.from("crm_lead_links").insert({
organization_id: ctx.organization_id,
lead_id: leadId,
target_kind: ALVO_DE_VINCULO_DO_AGENDAMENTO,
target_id: args.appointmentId,
link_kind: VINCULO_DE_AGENDAMENTO,
created_by_user_id: ctx.actor.type === "user" ? ctx.actor.id : null,
});
await emitLeadActivity(supabase, {
organizationId: ctx.organization_id,
leadId,
contactId: args.contactId,
type: args.atividade as never,
sourceModule: "agenda",
sourceId: args.appointmentId,
actor: ctx.actor,
reason: `${args.nomeDoTipo} — ${args.atividade}`,
payload: args.outcome ? {outcome:args.outcome} : {},
// ⚠️ `sync` não existe no CHECK de `actor_kind`; `autorParaTimeline` mapeia.
actorKind: autorParaTimeline(ctx.actor.type),
} as never);
}
/**
* O negócio ativo do contato — pela MESMA régua do resto do produto.
*
* `resolveActiveLeadForContact` distingue três desfechos que um `limit(2)` não
* distingue: roteou, `no_open_lead` e `ambiguous_open_leads`. Os dois últimos
* NÃO são erro: o agendamento existe e a atividade não nasce, porque não há
* negócio a que ancorar.
*/
async function leadAtivoDoContato(
supabase: SB,
ctx: HandlerCtx,
contactId: string,
): Promise<string | null> {
const [{ data: candidatos }, { data: padrao }] = await Promise.all([
supabase
.from("crm_leads")
.select("id, organization_id, pipeline_id, status, last_activity_at, created_at")
.eq("organization_id", ctx.organization_id)
.eq("contact_id", contactId),
supabase
.from("crm_pipelines")
.select("id")
.eq("organization_id", ctx.organization_id)
.eq("is_default", true)
.eq("is_archived", false)
.maybeSingle(),
]);
const rota = resolveActiveLeadForContact((candidatos ?? []) as LeadCandidate[], {
defaultPipelineId: (padrao as { id: string } | null)?.id ?? null,
});
return rota.routed ? rota.leadId : null;
}
async function alteraComRevisao(supabase:SB,ctx:HandlerCtx,id:string,revision:number,patch:Record<string,unknown>):Promise<Record<string,unknown>> {
const {data,error}=await supabase.rpc("fn_appointment_change",{p_org:ctx.organization_id,p_id:id,p_revision:revision,p_patch:patch});
if(error) throw new ApiError(error.code === "40001" ? 409 : error.code === "42501" ? 403 : error.code === "P0002" ? 404 : 422,
error.code === "40001" ? "conflict" : error.code === "42501" ? "forbidden" : error.code === "P0002" ? "not_found" : "validation_failed",undefined,ctx.requestId,
error.code === "40001" ? "Este compromisso mudou. Atualize os dados antes de confirmar novamente." : "Não foi possível alterar este compromisso. Confira a presença, o horário e a mensagem vinculada.");
return data as Record<string,unknown>;
}