10 KiB
Upsell Verification — 3 Validaciones (Pending/Accepted/Rejected)
Contexto
El negocio quiere que una oferta de upsell "válida" cumpla tres validaciones antes de contar como verificada, y que la tabla Upsell Verification del dashboard admin/owner (components/dashboard/AcceptedTicketsVerification.tsx, entregada en GFIBER-648, ya mergeada a dev) muestre un estado único derivado en vez de solo el widget de clasificación que tiene hoy.
El diseño completo ya existe como plan aprobado en Docmost (space GFiber → "Upsell_Verification_—Plan_3_Validaciones(Pending-Accepted-Rejected)" → "Plan_(Español)"), marcado ahí como "pendiente de aprobación de implementación". Antes de ejecutarlo se verificó cada afirmación del documento contra el código real (3 agentes de exploración en paralelo + lectura directa de los archivos críticos). El plan de Docmost resultó mayormente preciso, con una corrección material importante (ver "Correcciones" abajo) y dos decisiones de UI ya resueltas con el usuario.
Decisiones confirmadas con el usuario:
- La rama sale de
dev(no demain, pese a quedocs/knowledge/conventions.mddocumente lo contrario — la práctica real reciente, GFIBER-648/streaks/ranking, rama todo desdedev). - El kebab (⋯) que abre el modal usa el
IconButtonreal del design system (seria su primer uso real en la app). - El check de objeción dentro del modal es un único
ToggleChipbinario (no dos chips comoinitiation_type).
Corrección material al plan de Docmost: el documento asume que createTicket/updateTicket (app/(protected)/upsell-evaluator/actions.ts) hacen spread directo de buildTicketPayload() hacia el insert/update de Supabase. Es falso: el spread real ocurre un nivel arriba, en UpsellEvaluator.tsx:101-107; actions.ts revalida y mapea cada campo explícitamente campo-por-campo antes de tocar la DB (líneas 236-257 y 324-358). Persistir full_flow_completed requiere tocar actions.ts en tres puntos, no solo payloads.ts (ver Paso 5).
Diseño (validado contra el código)
Tres validaciones sobre cada ticket accepted, con estado derivado pending / accepted / rejected:
full_flow_completed(bool, automático) —truecuandostate.outcome !== nullenbuildTicketPayload, es decir, el agente llegó a Accept/Decline. Si esfalse→ status siemprerejected, es un backstop de integridad de datos, no editable por el admin.initiation_type(ya existe, GFIBER-648) — solo se reubica su UI de la fila de la tabla a un modal.objection_handled_correctly(bool nullable, nuevo) — verificado por admin: ¿el agente insistió tras una objeción? Sin revisar =null= pending.
function deriveVerificationStatus(t): "pending"|"accepted"|"rejected" {
if (!t.full_flow_completed) return "rejected";
if (t.objection_handled_correctly === false) return "rejected";
if (t.initiation_type === null || t.objection_handled_correctly === null) return "pending";
return "accepted";
}
Plan de ejecución (TDD — rojo antes que verde)
Paso 0 — Rama: git checkout dev && git pull && git checkout -b feat/upsell-verification-checks.
Paso 1 — Migración DB (bloqueante para todo lo demás). Archivo nuevo supabase/migrations/<timestamp posterior a 20260709140000>_add_verification_columns_to_upsell_tickets.sql, estilo idéntico a 20260624000000_add_initiation_type_to_upsell_tickets.sql:
full_flow_completed boolean not null default falsecon backfill crítico:update upsell_tickets set full_flow_completed = true where state in ('accepted', 'declined_hard', 'declined_soft'). Sin esto, todo ticketacceptedhistórico se veríarejectedal desplegar. (Verificado:no_pitch/no_offerquedan fuera correctamente — son estados donde el agente nunca llegó al Close stage, confirmado enutils/dashboard/metrics.ts:11-12yflow.ts:107).objection_handled_correctly boolean default null— nullable, sin backfill.- Comentario en la migración aclarando que no es lo mismo que
clients.objections_handled(plural, jsonb, qué scripts se usaron — concepto distinto).
Paso 2 — Regenerar tipos: supabase gen types typescript --local > utils/supabase/database.types.ts.
Paso 3 — utils/upsell/verification.ts (nuevo, función pura). Test primero: __tests__/upsell-verification-status.test.ts — tabla de verdad completa (las 3× combinaciones, especialmente full_flow_completed=false ganando sobre todo, y objection_handled_correctly=false ganando sobre initiation_type seteado). Sin dependencias de DB — puede desarrollarse en paralelo al Paso 1/2.
Paso 4 — utils/upsell/payloads.ts. Test primero: extender __tests__/upsell-payloads.test.ts (outcome:"accepted"→true, outcome:"declined"→true, outcome:null→false). Código: agregar full_flow_completed: boolean a TicketPayload (línea 66) y full_flow_completed: state.outcome !== null en buildTicketPayload (línea ~91).
Paso 5 — app/(protected)/upsell-evaluator/actions.ts (3 cambios). Test primero: extender __tests__/upsell-actions.test.ts con casos para createTicket/updateTicket (full_flow_completed: true/false persiste, valor no-boolean se ignora — mismo patrón que open_tech_ticket). Código:
full_flow_completed?: unknown;enCreateTicketInput(~línea 191) yUpdateTicketInput(~línea 292).- En
createTicket(~línea 238):if (typeof input.full_flow_completed === "boolean") payload.full_flow_completed = input.full_flow_completed; - En
updateTicket(~línea 326): mismo patrón sobreupdate.
Paso 6 — setObjectionHandled en app/(protected)/dashboard/actions.ts. Test primero: __tests__/dashboard-objection-handled.test.ts, clon de __tests__/dashboard-initiation-type.test.ts (permisos admin/owner, guard state==='accepted', acepta true/false/null, revalidatePath solo en éxito). Código: copiar setInitiationType (líneas 72-107) literal, actualizando objection_handled_correctly.
Paso 7 — Query de app/(protected)/dashboard/page.tsx. Extender el .select(...) (líneas 124-130) con , full_flow_completed, objection_handled_correctly. Sin test dedicado — se valida vía Paso 10 y el E2E manual.
Paso 8 — StatusBadge (design system, Jest). Test primero: extender StatusBadge.test.tsx (.forEach + casos dedicados accepted/rejected). Código: TStatus en index.tsx:4 +2 valores; style.module.scss: .accepted idéntico a .active (--fiber-green-light/--fiber-green-sold, decisión ya tomada en el doc original), .rejected con --fiber-red-light/--fiber-red (tokens confirmados, tokens.css:41-42). Extender StatusBadge.stories.tsx con 2 Story nuevos.
Paso 9 — TicketVerificationModal (componente nuevo, design system). Plantilla: AttendanceQuickModal (portal+mounted, foco inicial vía rAF — sin focus-trap real de librería, Escape-to-close, backdrop click-to-close, role="dialog" aria-modal="true", Tailwind inline sin .module.scss). Contenido: Check 1 solo-lectura (full_flow_completed), Check 2 dos ToggleChip (initiation_type, movido desde la fila), Check 3 un solo ToggleChip binario (objection_handled_correctly, decisión confirmada). Test primero: .test.tsx (no renderiza si open=false, Escape cierra, backdrop click cierra, callbacks de toggle, role="dialog" presente) + .test.cy.tsx (smoke mount) + .stories.tsx. Exportar en packages/design-system/src/components/index.tsx.
Paso 10 — Reescribir components/dashboard/AcceptedTicketsVerification.tsx. Test primero: reescribir __tests__/accepted-tickets-verification.test.tsx (columna Status con StatusBadge correcto por fixture, kebab IconButton abre el modal con los datos del ticket, callbacks del modal llaman setInitiationType/setObjectionHandled con args correctos incluyendo reset a null, router.refresh() solo en éxito; quitar los tests viejos de chips inline en la fila). Código: AcceptedTicket type +2 campos; quitar ClassificationCell y la columna initiation_type (líneas 73-106, 151-166); agregar columna status y columna actions (kebab IconButton, aria-label con nombre del agente); estado local managedTicket (patrón UserTable.tsx:35,82-98 con UserManageDrawer).
Paso 11 — Verificación de build: npm run lint && npm run test (raíz, Vitest), npm test dentro de packages/design-system (Jest), tsc --noEmit.
Riesgo principal
El backfill del Paso 1 es no-negociable: si no corre en el ambiente real, tickets accepted preexistentes se ven rejected en el dashboard sin que ningún test lo detecte (los tests solo cubren la función pura y el action, no el estado real de la tabla).
Archivos críticos
utils/upsell/payloads.ts,utils/upsell/verification.ts(nuevo)app/(protected)/upsell-evaluator/actions.tsapp/(protected)/dashboard/actions.ts,app/(protected)/dashboard/page.tsxcomponents/dashboard/AcceptedTicketsVerification.tsxpackages/design-system/src/components/StatusBadge/*,packages/design-system/src/components/TicketVerificationModal/*(nuevo, plantillaAttendanceQuickModal)supabase/migrations/<nuevo>.sql,utils/supabase/database.types.ts(regenerado)
Verificación end-to-end (no solo tests en verde)
- Aplicar la migración localmente, confirmar en DB que tickets
acceptedpreexistentes quedaron confull_flow_completed=true(backfill). npm run dev, login como agente, completar un flujo de Upsell Evaluator hasta Accept/Decline → confirmarfull_flow_completed=trueen DB. Repetir abandonando el flujo antes del Close stage → confirmarfalse.- Login como admin/owner,
/dashboard→ confirmar que el ticket abandonado muestrarejected(sin importar qué se marque en el modal — precedencia), el ticket recién aceptado muestrapending. - Abrir el modal (kebab
IconButton), marcarinitiation_type+ objeción=true → confirmaracceptedsin recargar. Marcar objeción=false → confirmar vuelve arejected. Resetear objeción a null → confirmar vuelve apending. - Recargar la página completa (F5, no solo
router.refresh()) → confirmar que persiste (valida el.select()extendido +revalidatePath). - Confirmar que un usuario con rol
userno puede invocarsetObjectionHandled/setInitiationType(rechazo por rol). - Revisar en Storybook las nuevas stories de
StatusBadge(Accepted/Rejected) y delTicketVerificationModal.
Nota no bloqueante
La rama remota agente-gfiber-648-admin-verification ya está completamente contenida en dev (mismos commits) — está obsoleta, recomendable borrarla en algún momento fuera de este trabajo.