@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
88import { z } from "zod" ;
99
1010import { audit } from "@/lib/audit" ;
11+ import { listSelectableChannels , type SelectableChannel } from "@/lib/channels/selectable" ;
1112import { createAdminClient } from "@/lib/supabase/admin" ;
1213import { aiAgentDefaultSchema , type PromptTemplate } from "@/lib/schemas/onboarding" ;
1314import { 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 */
3559async 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
84151export 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
88164export 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
0 commit comments