Files
qwen3-6-lora/data/raw/sanitized/plans/puedes-leer-de-docmost-virtual-rain.md

10 KiB
Raw Permalink Blame History

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 de main, pese a que docs/knowledge/conventions.md documente lo contrario — la práctica real reciente, GFIBER-648/streaks/ranking, rama todo desde dev).
  • El kebab (⋯) que abre el modal usa el IconButton real del design system (seria su primer uso real en la app).
  • El check de objeción dentro del modal es un único ToggleChip binario (no dos chips como initiation_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:

  1. full_flow_completed (bool, automático) — true cuando state.outcome !== null en buildTicketPayload, es decir, el agente llegó a Accept/Decline. Si es false → status siempre rejected, es un backstop de integridad de datos, no editable por el admin.
  2. initiation_type (ya existe, GFIBER-648) — solo se reubica su UI de la fila de la tabla a un modal.
  3. 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 false con backfill crítico: update upsell_tickets set full_flow_completed = true where state in ('accepted', 'declined_hard', 'declined_soft'). Sin esto, todo ticket accepted histórico se vería rejected al desplegar. (Verificado: no_pitch/no_offer quedan fuera correctamente — son estados donde el agente nunca llegó al Close stage, confirmado en utils/dashboard/metrics.ts:11-12 y flow.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:

  1. full_flow_completed?: unknown; en CreateTicketInput (~línea 191) y UpdateTicketInput (~línea 292).
  2. En createTicket (~línea 238): if (typeof input.full_flow_completed === "boolean") payload.full_flow_completed = input.full_flow_completed;
  3. En updateTicket (~línea 326): mismo patrón sobre update.

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.ts
  • app/(protected)/dashboard/actions.ts, app/(protected)/dashboard/page.tsx
  • components/dashboard/AcceptedTicketsVerification.tsx
  • packages/design-system/src/components/StatusBadge/*, packages/design-system/src/components/TicketVerificationModal/* (nuevo, plantilla AttendanceQuickModal)
  • supabase/migrations/<nuevo>.sql, utils/supabase/database.types.ts (regenerado)

Verificación end-to-end (no solo tests en verde)

  1. Aplicar la migración localmente, confirmar en DB que tickets accepted preexistentes quedaron con full_flow_completed=true (backfill).
  2. npm run dev, login como agente, completar un flujo de Upsell Evaluator hasta Accept/Decline → confirmar full_flow_completed=true en DB. Repetir abandonando el flujo antes del Close stage → confirmar false.
  3. Login como admin/owner, /dashboard → confirmar que el ticket abandonado muestra rejected (sin importar qué se marque en el modal — precedencia), el ticket recién aceptado muestra pending.
  4. Abrir el modal (kebab IconButton), marcar initiation_type + objeción=true → confirmar accepted sin recargar. Marcar objeción=false → confirmar vuelve a rejected. Resetear objeción a null → confirmar vuelve a pending.
  5. Recargar la página completa (F5, no solo router.refresh()) → confirmar que persiste (valida el .select() extendido + revalidatePath).
  6. Confirmar que un usuario con rol user no puede invocar setObjectionHandled/setInitiationType (rechazo por rol).
  7. Revisar en Storybook las nuevas stories de StatusBadge (Accepted/Rejected) y del TicketVerificationModal.

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.