diff --git a/skill/actualizar-mi-bot.md b/skill/actualizar-mi-bot.md index a497fac6..13db0302 100644 --- a/skill/actualizar-mi-bot.md +++ b/skill/actualizar-mi-bot.md @@ -166,6 +166,8 @@ Si la versión nueva trae cambios en el esquema de la base (`src/db/schema.sql` git diff HEAD@{1} HEAD --name-only | grep "src/db/schema.sql" && pnpm db:apply:remote ``` Esto **agrega** columnas/tablas nuevas. No borra los datos existentes (conversaciones, leads). +Si el esquema trae el `UPDATE` de `open_ticket_id`, también libera conversaciones +cuyo ticket ya se resolvió o ya no existe — sin eso el follow-up las saltaba para siempre. ## Paso 8 — Publicar y sincronizar la base de conocimiento diff --git a/src/db/schema.sql b/src/db/schema.sql index f50800aa..6e0eb24e 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -203,3 +203,10 @@ CREATE TABLE IF NOT EXISTS template_sends ( UNIQUE (campaign_key, conversation_id) ); CREATE INDEX IF NOT EXISTS idx_template_sends_time ON template_sends(sent_at); + +-- Release conversations locked to a resolved ticket or a missing ticket id. +-- Safe to re-run on every db:apply. Matches TicketsRepo.cleanupStaleOpenTicketRefs. +UPDATE conversations SET open_ticket_id = NULL +WHERE open_ticket_id IS NOT NULL + AND (open_ticket_id NOT IN (SELECT id FROM tickets) + OR open_ticket_id IN (SELECT id FROM tickets WHERE status = 'resolved')); diff --git a/src/db/tickets.ts b/src/db/tickets.ts index 62ee2cc7..3f2c17ed 100644 --- a/src/db/tickets.ts +++ b/src/db/tickets.ts @@ -19,6 +19,14 @@ export interface CreateTicketInput { transcript: string; } +/** Idempotent. No semicolons inside — schema.sql splitter is `;`. */ +export const CLEANUP_STALE_OPEN_TICKETS_SQL = ` +UPDATE conversations SET open_ticket_id = NULL +WHERE open_ticket_id IS NOT NULL + AND (open_ticket_id NOT IN (SELECT id FROM tickets) + OR open_ticket_id IN (SELECT id FROM tickets WHERE status = 'resolved')) +`.trim(); + export class TicketsRepo { constructor(private readonly db: Db) {} @@ -47,5 +55,20 @@ export class TicketsRepo { "UPDATE tickets SET status = 'resolved', resolved_at = ?, resolved_by = ? WHERE id = ?", [Date.now(), resolvedBy, id], ); + // Conversation ids are permanent (channel:user). open_ticket_id must drop + // when the human case closes, or Pro follow-ups / surveys skip that lead forever. + await this.db.run( + "UPDATE conversations SET open_ticket_id = NULL WHERE open_ticket_id = ?", + [id], + ); + } + + /** + * One-shot cleanup for rows still pointing at a resolved ticket or a missing + * ticket id (orphans). Safe to re-run. Same statement lives in schema.sql + * so `pnpm db:apply:remote` heals existing bots on update. + */ + async cleanupStaleOpenTicketRefs(): Promise { + await this.db.run(CLEANUP_STALE_OPEN_TICKETS_SQL); } } diff --git a/src/followup/run.ts b/src/followup/run.ts index ca6a6e74..280bb11e 100644 --- a/src/followup/run.ts +++ b/src/followup/run.ts @@ -9,9 +9,10 @@ * hablando, eso es un pendiente del agente, no un follow-up). * • Y es un lead que VALE el toque: venta abierta detectada por el Analista * (sale_opportunity) o 4+ mensajes del cliente (engagement alto). - * • Nunca a conversaciones pausadas (takeover del dueño), nunca por el canal - * instagram oficial (apagado), y UNA sola vez por conversación de por vida - * (followup_sends es el claim). Cap por corrida y cap diario. + * • Nunca a conversaciones pausadas (takeover del dueño), nunca con un ticket + * humano abierto (open_ticket_id), nunca por el canal instagram oficial + * (apagado), y UNA sola vez por conversación de por vida (followup_sends + * es el claim). Cap por corrida y cap diario. * * El mensaje lo redacta el modelo rápido en la voz del bot (breve, no pushy), * se persiste como mensaje del asistente (visible en la Bandeja) y sale por el @@ -69,6 +70,7 @@ export async function pickFollowupCandidates( LEFT JOIN followup_sends f ON f.conversation_id = c.id WHERE f.conversation_id IS NULL AND c.channel != 'instagram' + AND c.open_ticket_id IS NULL AND (c.paused_until IS NULL OR c.paused_until < ?) ) WHERE last_user_at IS NOT NULL diff --git a/test/db/tickets.test.ts b/test/db/tickets.test.ts index 86554389..2873001e 100644 --- a/test/db/tickets.test.ts +++ b/test/db/tickets.test.ts @@ -2,13 +2,18 @@ import { describe, it, expect, beforeEach } from "vitest"; import { createTestMiniflare } from "../helpers/miniflareSetup"; import { Db } from "../../src/db/client"; import { TicketsRepo } from "../../src/db/tickets"; +import { ConversationsRepo } from "../../src/db/conversations"; let repo: TicketsRepo; +let convs: ConversationsRepo; +let db: Db; beforeEach(async () => { const mf = await createTestMiniflare(); const d1 = await mf.getD1Database("DB"); - repo = new TicketsRepo(new Db(d1 as any)); + db = new Db(d1 as any); + repo = new TicketsRepo(db); + convs = new ConversationsRepo(db); }); describe("TicketsRepo", () => { @@ -46,4 +51,61 @@ describe("TicketsRepo", () => { expect(list).toHaveLength(1); expect(list[0].summary).toBe("a"); }); + + it("resolve clears conversations.open_ticket_id for that ticket only", async () => { + const locked = await convs.getOrCreate("telegram", "user_locked"); + const other = await convs.getOrCreate("telegram", "user_other"); + const ticketId = await repo.create({ + conversationId: locked.id, + category: "other", + summary: "escalado", + transcript: "", + }); + const otherTicketId = await repo.create({ + conversationId: other.id, + category: "other", + summary: "otro caso", + transcript: "", + }); + await convs.setOpenTicket(locked.id, ticketId); + await convs.setOpenTicket(other.id, otherTicketId); + + await repo.resolve(ticketId, "agente@ejemplo.com"); + + expect((await convs.getById(locked.id))?.open_ticket_id).toBeNull(); + expect((await convs.getById(other.id))?.open_ticket_id).toBe(otherTicketId); + }); + + it("cleanupStaleOpenTicketRefs drops resolved and orphan refs, keeps open ones", async () => { + const resolvedConv = await convs.getOrCreate("telegram", "user_resolved"); + const orphanConv = await convs.getOrCreate("telegram", "user_orphan"); + const openConv = await convs.getOrCreate("telegram", "user_open"); + + const resolvedId = await repo.create({ + conversationId: resolvedConv.id, + category: "other", + summary: "ya cerrado", + transcript: "", + }); + const openId = await repo.create({ + conversationId: openConv.id, + category: "other", + summary: "sigue abierto", + transcript: "", + }); + await convs.setOpenTicket(resolvedConv.id, resolvedId); + await convs.setOpenTicket(openConv.id, openId); + await convs.setOpenTicket(orphanConv.id, "ticket-fantasma"); + // Simulate a pre-fix resolve: ticket is resolved, conversation still locked. + await db.run( + "UPDATE tickets SET status = 'resolved', resolved_at = ?, resolved_by = ? WHERE id = ?", + [Date.now(), "legacy", resolvedId], + ); + + await repo.cleanupStaleOpenTicketRefs(); + + expect((await convs.getById(resolvedConv.id))?.open_ticket_id).toBeNull(); + expect((await convs.getById(orphanConv.id))?.open_ticket_id).toBeNull(); + expect((await convs.getById(openConv.id))?.open_ticket_id).toBe(openId); + }); }); diff --git a/test/followup/run.test.ts b/test/followup/run.test.ts index 1d3508b0..c84a61d7 100644 --- a/test/followup/run.test.ts +++ b/test/followup/run.test.ts @@ -131,6 +131,26 @@ describe("pickFollowupCandidates — selección", () => { const c = await pickFollowupCandidates(env, NOW, 10); expect(c).toHaveLength(0); }); + + it("salta conversaciones con ticket humano abierto y las vuelve a elegir al resolver", async () => { + const { TicketsRepo } = await import("../../src/db/tickets"); + const tickets = new TicketsRepo(db); + const convId = await seed("handoff"); + await markHot(convId); + const ticketId = await tickets.create({ + conversationId: convId, + category: "other", + summary: "escalado", + transcript: "", + }); + await convs.setOpenTicket(convId, ticketId); + + expect(await pickFollowupCandidates(env, NOW, 10)).toHaveLength(0); + + await tickets.resolve(ticketId, "agente@ejemplo.com"); + const after = await pickFollowupCandidates(env, NOW, 10); + expect(after.map((x) => x.id)).toEqual([convId]); + }); }); describe("runFollowups — envío y garantías", () => { diff --git a/test/tools/handoffHuman.test.ts b/test/tools/handoffHuman.test.ts index 557e899f..32b91dca 100644 --- a/test/tools/handoffHuman.test.ts +++ b/test/tools/handoffHuman.test.ts @@ -44,5 +44,7 @@ describe("handoffHumanTool", () => { const list = await tickets.listOpen(); expect(list).toHaveLength(1); expect(list[0].summary).toContain("María"); + const convs = new ConversationsRepo(new Db(env.DB)); + expect((await convs.getById(convId))?.open_ticket_id).toBe(list[0].id); }); });