mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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>
726 lines
30 KiB
TypeScript
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>;
|
|
}
|