91 lines
10 KiB
Markdown
91 lines
10 KiB
Markdown
# 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.
|
||
|
||
```ts
|
||
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.
|