Skip to content

Commit f261cef

Browse files
authored
Merge pull request #149 from melgarafael/fix/jmpo-138
Leva adiante o #138 do @jmpo. Merge commit (não squash) para preservar a autoria dele no histórico da main.
2 parents fbdff35 + 84e5e81 commit f261cef

73 files changed

Lines changed: 6056 additions & 386 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/actions/onboarding/createDefaultAgent.ts

Lines changed: 162 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
88
import { z } from "zod";
99

1010
import { audit } from "@/lib/audit";
11+
import { listSelectableChannels, type SelectableChannel } from "@/lib/channels/selectable";
1112
import { createAdminClient } from "@/lib/supabase/admin";
1213
import { aiAgentDefaultSchema, type PromptTemplate } from "@/lib/schemas/onboarding";
1314
import { requireOnboardingCtx, patchOnboardingState, OnboardingError } from "./_shared";
@@ -18,35 +19,73 @@ const PROMPT_BODIES: Record<PromptTemplate, string> = {
1819
support_minimal: `Você é um(a) agente de suporte minimalista. Responda em frases curtas, peça apenas o necessário e direcione para um humano quando a confiança for baixa.`,
1920
};
2021

22+
/** O agente padrão desta organização, do jeito que este passo precisa vê-lo. */
23+
interface AgenteDoOnboarding {
24+
id: string;
25+
published_version_id: string | null;
26+
}
27+
2128
/**
22-
* Publica a 1ª versão do agente criado no onboarding.
29+
* O que aconteceu com a 1ª versão — e por que "não há canal" e "não deu para
30+
* saber" são desfechos SEPARADOS.
31+
*
32+
* `no_channel` é um estado CONHECIDO do produto: quem pulou o WhatsApp não tem
33+
* número, a versão exige `channel_session_id`, e o agente fica rascunho de
34+
* propósito (a lista de agentes já mostra "Rascunho"). `failed` é o estado
35+
* DESCONHECIDO: a consulta não respondeu, então não se sabe se há canal.
36+
* Colapsar os dois no mesmo `return` seria engolir erro — e engolir erro aqui
37+
* significa terminar o onboarding com um agente mudo sem ninguém saber por quê.
38+
*/
39+
type PublishOutcome =
40+
| { published: true }
41+
| { published: false; reason: "no_channel" }
42+
| { published: false; reason: "failed"; message: string };
43+
44+
function mensagemDoErro(err: unknown): string {
45+
return err instanceof Error ? err.message : String(err);
46+
}
47+
48+
/**
49+
* Publica a 1ª versão do agente criado no onboarding. **Nunca lança**: devolve
50+
* o desfecho para quem chama decidir o que a tela mostra.
2351
*
2452
* Sem isso, o passo "Configurar IA" gravava só a linha em `ai_agents` — formato
2553
* do `rag_bot` legado. Só que os dois runtimes atuais (o dispatcher do CRM e o
2654
* agent-engine) resolvem o agente por
2755
* `join ai_agent_versions on v.id = a.published_version_id`, então um agente
2856
* sem versão publicada é invisível para ambos: a pessoa terminava o onboarding
2957
* com um "Atendente IA" que nunca responderia uma única mensagem.
30-
*
31-
* Se ainda não existe número de WhatsApp (a versão exige `channel_session_id`),
32-
* o agente fica como rascunho — e o badge da lista passa a dizer isso, em vez
33-
* de anunciar "Publicado".
3458
*/
3559
async function publishFirstVersion(
3660
admin: ReturnType<typeof createAdminClient>,
3761
orgId: string,
38-
agentId: string,
62+
agent: AgenteDoOnboarding,
3963
systemPrompt: string,
4064
userId: string,
41-
): Promise<void> {
42-
const { data: session } = await admin
43-
.from("channel_sessions")
44-
.select("id")
45-
.eq("organization_id", orgId)
46-
.order("created_at", { ascending: true })
47-
.limit(1)
48-
.maybeSingle();
49-
if (!session?.id) return; // sem canal ainda: fica rascunho, e a lista mostra "Rascunho"
65+
): Promise<PublishOutcome> {
66+
// Já publicado numa passagem anterior: republicar colidiria com
67+
// `ai_agent_versions_unique_number` sem ganhar nada.
68+
if (agent.published_version_id) return { published: true };
69+
70+
// Mesma lista que os seletores das telas de IA: canal arquivado não é destino
71+
// válido de agente, e publicar uma versão apontando para um deixaria o
72+
// onboarding terminar com um agente que nunca receberia uma mensagem.
73+
//
74+
// Ela LANÇA em erro de banco, e isso é correto lá: um seletor que devolve
75+
// lista vazia quando a consulta falhou é indistinguível de "esta organização
76+
// não tem número", e convida a parear de novo um aparelho que já está no ar.
77+
// Aqui não é um seletor — é a decisão "publica ou fica rascunho", tomada
78+
// DEPOIS de a linha em `ai_agents` já existir. Deixar o throw subir furava o
79+
// `CreateAgentResult` (que trata todos os outros pontos de falha) e o passo
80+
// terminava sem gravar estado, sem audit, sem evento e sem dizer nada na tela.
81+
let canais: SelectableChannel[];
82+
try {
83+
canais = await listSelectableChannels(admin, orgId);
84+
} catch (err) {
85+
return { published: false, reason: "failed", message: mensagemDoErro(err) };
86+
}
87+
const [canal] = canais;
88+
if (!canal) return { published: false, reason: "no_channel" };
5089

5190
const { data: model } = await admin
5291
.from("ai_models")
@@ -56,33 +95,70 @@ async function publishFirstVersion(
5695
.limit(1)
5796
.maybeSingle();
5897

59-
const { data: version } = await admin
98+
const { data: version, error: versionErr } = await admin
6099
.from("ai_agent_versions")
61100
.insert({
62101
organization_id: orgId,
63-
agent_id: agentId,
102+
agent_id: agent.id,
64103
version_number: 1,
65104
system_prompt: systemPrompt,
66105
provider: "anthropic",
106+
// Fallback do modelo vem da main (catálogo do 0104); o canal vem daqui
107+
// (listagem que exclui arquivado). O hunk pedia as DUAS metades: ficar com
108+
// um lado só perderia o modelo atual ou o filtro de canal excluído.
67109
model: (model?.model_id as string) ?? "claude-sonnet-5",
68-
channel_session_id: session.id as string,
110+
channel_session_id: canal.id,
69111
status: "published",
70112
published_at: new Date().toISOString(),
71113
created_by: userId,
72114
})
73115
.select("id")
74116
.single();
75-
if (!version?.id) return;
76117

77-
await admin
118+
let versionId = version?.id ?? null;
119+
if (!versionId && versionErr?.code === "23505") {
120+
// A v1 já existe: uma passagem anterior gravou a versão e caiu antes de
121+
// apontar o agente para ela. Repetir o passo passou a ser o que o usuário
122+
// faz quando a tela pede — então ele não pode bater em "duplicate key"
123+
// para sempre. Repontar é o conserto, não um novo INSERT.
124+
const { data: existente } = await admin
125+
.from("ai_agent_versions")
126+
.select("id")
127+
.eq("organization_id", orgId)
128+
.eq("agent_id", agent.id)
129+
.eq("version_number", 1)
130+
.maybeSingle();
131+
versionId = existente?.id ?? null;
132+
}
133+
if (!versionId) {
134+
return {
135+
published: false,
136+
reason: "failed",
137+
message: versionErr?.message ?? "ai_agent_versions_insert_sem_id",
138+
};
139+
}
140+
141+
const { error: pointErr } = await admin
78142
.from("ai_agents")
79-
.update({ published_version_id: version.id as string })
80-
.eq("id", agentId)
143+
.update({ published_version_id: versionId })
144+
.eq("id", agent.id)
81145
.eq("organization_id", orgId);
146+
if (pointErr) return { published: false, reason: "failed", message: pointErr.message };
147+
148+
return { published: true };
82149
}
83150

84151
export type CreateAgentResult =
85-
| { ok: true; agent_id: string }
152+
/**
153+
* O agente existe. `publish_error` presente = ficou RASCUNHO porque não deu
154+
* para decidir a publicação; ausente = publicado (ou rascunho deliberado por
155+
* ainda não haver número, caso em que o wizard já seguiu com um `redirect`).
156+
*
157+
* Mesmo contrato do passo de convites, que também recusa redirecionar quando
158+
* a parte que podia falhar falhou (`sendOnboardingInvites` → `undelivered`):
159+
* avançar calado seria a UI mentindo sobre o que o servidor conseguiu fazer.
160+
*/
161+
| { ok: true; agent_id: string; publish_error?: string }
86162
| { ok: false; error: "auth_required" | "no_active_org" | "invalid_input" | "db_error"; details?: unknown };
87163

88164
export async function createDefaultAgent(formData: FormData): Promise<CreateAgentResult> {
@@ -110,35 +186,62 @@ export async function createDefaultAgent(formData: FormData): Promise<CreateAgen
110186
}
111187

112188
const admin = createAdminClient();
113-
// Reset existing default to avoid trigger collisions if any.
114-
await admin
189+
const systemPrompt = PROMPT_BODIES[input.prompt_template];
190+
191+
// O agente padrão do onboarding é UM por organização, e o banco já garante
192+
// isso: `ai_agents_one_default_per_org` é índice único parcial em
193+
// (organization_id) where is_default. Nenhum outro caminho do produto grava
194+
// `is_default = true` (todos os outros INSERTs em `ai_agents` gravam false),
195+
// então "o default desta org" É "o agente que este passo criou" — chave de
196+
// reaproveitamento que não depende de nenhuma escrita anterior ter dado certo.
197+
//
198+
// O código antes fazia o oposto: rebaixava o default existente e inseria
199+
// outro. Enquanto o passo só terminava em redirect isso nunca aparecia; agora
200+
// que uma falha na publicação devolve o usuário para esta tela, o segundo
201+
// clique criaria um "Atendente IA" órfão por clique — todos invisíveis para o
202+
// runtime, e nenhum deles o padrão. Repetir o passo tem que ser inofensivo.
203+
const { data: reaproveitado, error: reuseErr } = await admin
115204
.from("ai_agents")
116-
.update({ is_default: false })
205+
.update({ name: input.name, system_prompt: systemPrompt, is_active: true })
117206
.eq("organization_id", ctx.orgId)
118-
.eq("is_default", true);
207+
.eq("is_default", true)
208+
.select("id, published_version_id")
209+
.maybeSingle();
119210

120-
const { data, error } = await admin
121-
.from("ai_agents")
122-
.insert({
123-
organization_id: ctx.orgId,
124-
name: input.name,
125-
system_prompt: PROMPT_BODIES[input.prompt_template],
126-
is_default: true,
127-
is_active: true,
128-
created_by: ctx.userId,
129-
})
130-
.select("id")
131-
.single();
211+
if (reuseErr) {
212+
return { ok: false, error: "db_error", details: reuseErr.message };
213+
}
214+
215+
let agent: AgenteDoOnboarding | null = reaproveitado;
216+
if (!agent) {
217+
const { data, error } = await admin
218+
.from("ai_agents")
219+
.insert({
220+
organization_id: ctx.orgId,
221+
name: input.name,
222+
system_prompt: systemPrompt,
223+
is_default: true,
224+
is_active: true,
225+
created_by: ctx.userId,
226+
})
227+
.select("id, published_version_id")
228+
.single();
132229

133-
if (error || !data) {
134-
return { ok: false, error: "db_error", details: error?.message };
230+
if (error || !data) {
231+
return { ok: false, error: "db_error", details: error?.message };
232+
}
233+
agent = data;
135234
}
136235

137-
await publishFirstVersion(admin, ctx.orgId, data.id as string, PROMPT_BODIES[input.prompt_template], ctx.userId);
236+
const publicacao = await publishFirstVersion(admin, ctx.orgId, agent, systemPrompt, ctx.userId);
138237

238+
// Estado, audit e evento saem em QUALQUER desfecho da publicação: o agente
239+
// existe, e o passo do onboarding é "configurar IA", não "publicar". Deixar
240+
// de gravá-los por causa da versão era o que fazia o wizard esquecer um passo
241+
// que na verdade aconteceu.
139242
try {
140243
await patchOnboardingState(ctx.orgId, {
141-
ai: { agent_id: data.id as string, prompt_template: input.prompt_template },
244+
ai: { agent_id: agent.id, prompt_template: input.prompt_template },
142245
});
143246
} catch (err) {
144247
if (err instanceof OnboardingError) return { ok: false, error: "db_error", details: err.message };
@@ -150,17 +253,30 @@ export async function createDefaultAgent(formData: FormData): Promise<CreateAgen
150253
actorUserId: ctx.userId,
151254
organizationId: ctx.orgId,
152255
resourceType: "ai_agent",
153-
resourceId: data.id as string,
154-
metadata: { prompt_template: input.prompt_template, name: input.name },
256+
resourceId: agent.id,
257+
metadata: {
258+
prompt_template: input.prompt_template,
259+
name: input.name,
260+
published: publicacao.published,
261+
...(publicacao.published ? {} : { publish_blocked_by: publicacao.reason }),
262+
},
155263
});
156264

157265
// Emit a domain event for downstream listeners (Spec 01 §7 event log).
158266
await admin.from("event_log").insert({
159267
organization_id: ctx.orgId,
160268
event_type: "ai_agent.created",
161-
payload: { agent_id: data.id, source: "onboarding" },
269+
payload: { agent_id: agent.id, source: "onboarding", published: publicacao.published },
162270
});
163271

272+
// Não deu para SABER se há canal: não publica (falha fechado na ação) e não
273+
// avança (falha aberto na informação) — a tela explica e oferece seguir. Um
274+
// redirect aqui deixaria como única pista um badge "Rascunho" numa tela que a
275+
// pessoa ainda não viu.
276+
if (!publicacao.published && publicacao.reason === "failed") {
277+
return { ok: true, agent_id: agent.id, publish_error: publicacao.message };
278+
}
279+
164280
redirect("/onboarding/invite-team");
165281
}
166282

app/api/v1/admin/incidents/[id]/resolve/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,5 @@ export async function POST(
111111
},
112112
});
113113

114-
return ok({ data: { id, status: "resolved" } }, { requestId });
114+
return ok({ id, status: "resolved" }, { requestId });
115115
}

0 commit comments

Comments
 (0)