Files
qwen3-6-lora/data/raw/sanitized/plans/puedes-leer-el-ticket-glimmering-fox.md
T

14 KiB

GFIBER-683 — Allow admins to correct offer data (accepted/rejected override + history)

Context

Jira GFIBER-683: "Agents in the pilot group have submitted incorrect upsells that compromised reporting metrics calculation. We need a mechanism to allow admins to validate, edit and correct these wrong entries."

Concretely: at the end of the Upsell Evaluator flow, an agent records whether the customer Accepted or Rejected the offer (upsell_tickets.state, mapped from the enum: accepted / declined_hard in practice — the only two "declined" the current UI can produce). Agents sometimes pick the wrong one by mistake. Since every dashboard KPI, rate tile, and export re-reads state live on every request, one wrong pick silently skews reporting metrics with no way to fix it today.

A detailed design already exists in Docmost (space GFiber → page Upsell_State_Override_—Plan_Historial_de_Cambios(Accepted-Rejected), ES/EN subpages, written 2026-07-09) and was refined further in this conversation against the current state of the repo. This plan supersedes the Docmost draft on two points where the repo/requirements moved since it was written (see below); the rest of the Docmost design (migration, RPC, RLS, server actions, tests) still holds and an Explore agent re-verified every file path/helper name against today's code.

What changed vs. the Docmost draft, confirmed with the user in this session:

  1. Table scope widened. Today app/(protected)/dashboard/page.tsx fetches only state = "accepted" tickets for the "Offer Verification" table (ValidationsCardAcceptedTicketsVerification). An agent who mis-marked an accepted offer as Rejected produces a declined_hard ticket that never appears in this table — there'd be nothing to click "History"/override on. Fix: broaden the query to state IN ('accepted', 'declined_hard') — the only two states this feature's toggle ever produces or corrects. Not all 6 enum values — no_pitch, declined_soft, no_offer, in_progress stay out of this table's scope.
  2. Menu design simplified. The Docmost draft split this into two menu items — "Verify" (existing modal + a new embedded "Override Outcome" section) and "History" (separate read-only modal). The user's clarified intent is simpler: leave the existing verification modal completely untouched, and add one new second menu item that opens a single new modal combining both the Accepted⇄Rejected override control and the change history in one view.
  3. no_pitch stays excluded from reclassification, per the user — confirmed as-is from the Docmost draft.

Design

1. Migration — new audit table + atomic RPC

New file supabase/migrations/<timestamp>_add_upsell_state_changes.sql (timestamp must sort after the latest existing migration, 20260714120000_add_sales_coach_notes_to_upsell_tickets.sql):

  • Table upsell_state_changes: id uuid pk, ticket_id uuid references upsell_tickets(id) on delete cascade, changed_by uuid references profiles(id) on delete set null, changed_at timestamptz default now(), old_state upsell_state not null, new_state upsell_state not null, reason text. Index on (ticket_id, changed_at desc).
  • Modeled directly on the existing audit-table precedent supabase/migrations/20260612120000_create_eligibility_checks.sql, but no INSERT/UPDATE/DELETE policy for any client role at all (unlike eligibility_checks, which lets any authenticated user insert) — every write goes through the RPC below. RLS: select policy gated to public.current_profile_role() in ('admin','owner') (reuse the existing helper from supabase/migrations/20260707130000_fix_profiles_update_policy_recursion.sql:29-42 — do not write a new one).
  • RPC change_upsell_ticket_state(p_ticket_id uuid, p_new_state upsell_state, p_reason text default null) returns upsell_tickets, security definer, set search_path = '': checks caller role is admin/owner via auth.uid()profiles.role (or current_profile_role()), checks p_new_state in ('accepted','declined_hard'), row-locks the ticket (for update), checks its current state is one of the terminal states ('accepted','declined_hard','declined_soft','no_offer') (excludes in_progress and no_pitch), checks it isn't already in p_new_state, then in one transaction: update upsell_tickets set state = p_new_state, initiation_type = null where id = ... and insert into upsell_state_changes (...). revoke ... from public; grant execute ... to authenticated.
  • Critical gotcha to get right: this RPC must be called via the session-scoped client (createClient() from utils/supabase/server.ts), not createAdminClient() (utils/supabase/admin.ts) — every other action in dashboard/actions.ts today uses createAdminClient() after an app-layer requireRole check, so this is a real deviation; call it out in the PR description/code review since it's easy to copy-paste the wrong client and have auth.uid() silently resolve to null inside the function.
  • Regenerate utils/supabase/database.types.ts after applying the migration to DEV.

2. Server Actions — app/(protected)/dashboard/actions.ts

Add two new actions, following the exact import/helper pattern already used in this file (getCurrentUserWithProfile from utils/supabase/get-current-user.ts, requireRole/RoleError from utils/roles.ts):

  • changeTicketState(ticketId, newState: "accepted" | "declined_hard", reason?): app-layer requireRole(profile, "admin", "owner") as defense-in-depth, then createClient().rpc("change_upsell_ticket_state", {...}), surface any raised exception as { error }, revalidatePath("/dashboard") on success.
  • getTicketStateHistory(ticketId): same role gate, then a plain read via createAdminClient() (no RLS-sensitive write here) joining profiles!changed_by(display_name, email), ordered changed_at desc.

3. Widen the verification table's query — app/(protected)/dashboard/page.tsx

  • Change the acceptedTicketsResult query (~line 128) from .eq("state", "accepted") to .in("state", ["accepted", "declined_hard"]), and add state to the .select(...) column list.
  • AcceptedTicket type (components/dashboard/AcceptedTicketsVerification.tsx:17-30) gets a new state: UpsellState field.
  • No other change needed here: ValidationsCard's "Information by Agent" tab (buildValidationSummaryByAgent) derives its pending/valid/rejected tally from full_flow_completed/initiation_type/objection_handled_correctly only, not from state, so it picks up the wider ticket set automatically and correctly.

4. UI — kebab becomes a 2-item menu; second item opens one combined modal

New DS component KebabMenu (packages/design-system/src/components/KebabMenu/): wraps the existing IconButton (⋯ trigger) and portal-renders (createPortal to document.body, positioned from the trigger's getBoundingClientRect() — same technique already used by AttendanceQuickModal/TicketVerificationModal, needed because this table can scroll horizontally and a non-portaled menu risks clipping, the same overflow-bug class already hit twice in this repo for the Attendance grid). Closes on Escape/outside-click/item-select. role="menu" / role="menuitem", arrow-key navigation. Props: { ariaLabel: string; items: { label: string; onSelect: () => void }[] }. Ships with .stories.tsx, .test.tsx, .test.cy.tsx per this repo's DS-first convention.

components/dashboard/AcceptedTicketsVerification.tsx Actions column (currently a single IconButton at lines 179-194 calling setManagedTicket(ticket)) becomes:

<KebabMenu
  ariaLabel={`Actions for ${agentName}'s ticket`}
  items={[
    { label: "Verify", onSelect: () => setManagedTicket(ticket) },
    { label: "Change Status", onSelect: () => setStateModalTicket(ticket) },
  ]}
/>

TicketVerificationModal is not modified — it keeps rendering exactly as it does today (the 4 existing sections: full_flow_completed, initiation_type, objection_handled_correctly, sales coach notes).

New DS component TicketStateModal (packages/design-system/src/components/TicketStateModal/), opened by the new "Change Status" menu item, built on the same portal/focus-trap/Escape-to-close shell as TicketVerificationModal/AttendanceQuickModal:

  • Header: agent name + Case ID + current OUTCOME_BADGE (reused from utils/upsell/outcomeDisplay.ts).
  • Override section: "Mark Accepted" / "Mark Rejected" buttons, disabled on whichever is currently active, plus an optional reason TextField (DS component already used elsewhere, e.g. StageClose.tsx's Order ID field). On submit, calls changeTicketState via the existing startTransition + router.refresh() pattern already used for the other actions in this file.
  • History section (same view, below the override controls): fetches getTicketStateHistory(ticketId) on open, renders a DataTable (same component SessionHistoryView uses): changed_atold_state → new_state (two OUTCOME_BADGEs with an arrow between) → changed_by_namereason. Empty state: "No manual overrides yet."
  • Footer: Close button.
  • Ships with .stories.tsx, .test.tsx, .test.cy.tsx.

AcceptedTicketsVerification.tsx gets one new local state (stateModalTicket) alongside the existing managedTicket, each independently rendering its own modal — mirrors the existing managed state pattern already used for TicketVerificationModal.

Side effects to call out in the PR description (no code change, just visibility)

  • Reclassifying a client's most-recent ticket changes that client's live cooloff/eligibility on the next Upsell Evaluator lookup (lookupClient() derives eligibility from the latest ticket's state live, no cache).
  • Dashboard KPIs, rate tiles, Session History, and the XLSX export all shift on next load — nothing else to invalidate.

Files touched

File Change
supabase/migrations/<ts>_add_upsell_state_changes.sql new table + RLS (admin/owner select-only, no insert/update/delete policy) + change_upsell_ticket_state() RPC
utils/supabase/database.types.ts regenerated
app/(protected)/dashboard/actions.ts new changeTicketState, new getTicketStateHistory
app/(protected)/dashboard/page.tsx widen acceptedTicketsResult query to state IN ('accepted','declined_hard'), add state to select
components/dashboard/AcceptedTicketsVerification.tsx AcceptedTicket.state field; swap lone kebab IconButton for KebabMenu (Verify / Change Status); new stateModalTicket local state rendering TicketStateModal
packages/design-system/src/components/KebabMenu/* new — menu trigger + story + tests
packages/design-system/src/components/TicketStateModal/* new — combined override+history modal + story + tests
packages/design-system/src/components/index.tsx export KebabMenu + TicketStateModal

Tests (TDD)

  • __tests__/dashboard-state-change.test.ts — mock .rpc() (this action calls the RPC, not .from().update()), role gate, success path, RPC exception messages surfaced verbatim, revalidatePath only on success.
  • __tests__/dashboard-state-history.test.ts — role gate, empty history → [], profiles!changed_by join mapping, DB error → { error }.
  • KebabMenu.test.tsx + .test.cy.tsx — open/close (click, Escape, outside-click, item-select), role="menu"/"menuitem", arrow-key nav, correct onSelect fired.
  • TicketStateModal.test.tsx + .test.cy.tsx — override buttons disabled on current state, click inactive button calls changeTicketState with right args, history rows render (old→new badges, changed_by_name, reason), empty state, focus-trap/Escape (mirror AttendanceQuickModal.test.tsx).
  • Extend __tests__/accepted-tickets-verification.test.tsx: Actions column renders KebabMenu with exactly two items; "Verify" opens the (unmodified) verification modal; "Change Status" opens TicketStateModal; mutually exclusive modal states.
  • Extend/add a page-level test or integration check confirming the widened query actually returns declined_hard tickets alongside accepted ones.
  • No Vitest can exercise the Postgres function body (no pgTAP in this repo) — the RPC's internal guards (role check, terminal-state check, atomicity) can only be verified against a real DEV database; call this out as a manual verification step, not a gap for more unit tests.

Verification (end-to-end)

  1. npm run lint && npm run test.
  2. Apply migration to DEV (supabase db push against vshcimexazocttjmyrqy), regenerate types.
  3. Exercise the RPC directly against DEV Postgres (SQL editor / supabase db execute): non-admin auth.uid() rejected; in_progress/no_pitch ticket rejected; same-state no-op rejected; a successful call leaves exactly one new upsell_state_changes row and the ticket's state/initiation_type updated together in the same query check.
  4. npm run dev as admin/owner: confirm the Offer Verification table now lists both accepted and declined_hard tickets. Open the kebab on an accepted ticket → "Change Status" → override to Rejected with a reason → confirm the row's status updates on refresh → reopen "Change Status" → confirm the new history row (old→new, your name, reason) appears above/below the override controls → reopen "Verify" → confirm initiation_type reset (Check 2 chips unselected).
  5. Confirm the cooloff side effect: pick a client whose latest ticket is the one just reclassified, run a fresh Upsell Evaluator lookup for that GAID, confirm the eligibility banner reflects the new cooloff window.

Git workflow

New branch from dev: feat/gfiber-683-offer-state-override, per this repo's convention (branch from main... actually per CLAUDE.md this repo branches feature work from main, and periodically maindev is merged for DEV testing — confirm target base branch matches how GFIBER-689/GFIBER-648 branches were structured before opening the PR).