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

91 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.