Fase 2: 04_sanitize.py - scrubbing de secretos de skills/agentes/plans (10 secretos unicos, gate en verde)
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
# GFIBER-622 — Session History (User Level)
|
||||
|
||||
## Context
|
||||
|
||||
Users currently have no way to review their past upsell evaluations. Every session
|
||||
is persisted in `upsell_tickets` (joined via `clients` for the GAID), but that data
|
||||
is only visible to admins in the Sales Dashboard. GFIBER-622 adds a personal history
|
||||
view so each agent can see their own sessions, their outcomes, and navigate into the
|
||||
full detail of any ticket.
|
||||
|
||||
**Acceptance Criteria (from Jira):**
|
||||
- Each user can access their previous sessions and corresponding data/outcomes
|
||||
- The history shows only sessions belonging to the current user (RLS already enforces this)
|
||||
- UI draft presented to team for validation → Improvements → Approved
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Routes (new)
|
||||
|
||||
```
|
||||
app/(protected)/upsell-evaluator/
|
||||
├── history/
|
||||
│ ├── page.tsx ← Server Component: listMyTickets() → <SessionHistoryView>
|
||||
│ └── [id]/
|
||||
│ └── page.tsx ← Server Component: getTicketById(id) → <TicketDetailView>
|
||||
```
|
||||
|
||||
### Components (new)
|
||||
|
||||
```
|
||||
app/(protected)/upsell-evaluator/
|
||||
├── _history/
|
||||
│ ├── SessionHistoryView.tsx ← 'use client' (SearchField needs state)
|
||||
│ └── TicketDetailView.tsx ← Server Component (pure render, Link for back)
|
||||
```
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `app/(protected)/upsell-evaluator/actions.ts` | Add `listMyTickets()` + `getTicketById()` |
|
||||
| `components/NavBar.tsx` | Add "History" nav link for all authenticated users |
|
||||
|
||||
---
|
||||
|
||||
## Design System — Components Used (zero DS extensions needed)
|
||||
|
||||
| Component | Where | How |
|
||||
|---|---|---|
|
||||
| `PageHeader` | Both pages | History: title + count badge; Detail: title + back `<Link>` in `actions` |
|
||||
| `DataTable` | `SessionHistoryView` | Generic columned table; `render` fn accepts `ReactNode` → `<Badge>` in outcome cell |
|
||||
| `Badge` | Outcome column + detail | `variant`: `success`→accepted, `danger`→declined_*, `info`→no_pitch/no_offer, `solid`→in_progress |
|
||||
| `AgentStatCard` | `SessionHistoryView` | 3-card grid: Total Sessions · Accepted · Conversion Rate |
|
||||
| `SearchField` | `SessionHistoryView` | Client-side filter on Case ID or GAID (no server round-trip) |
|
||||
| `Card` | `TicketDetailView` | 3 sections: Identification · Outcome · Call Details |
|
||||
| `DataRow` | `TicketDetailView` | label/value pairs per field, `tone` prop for outcome color |
|
||||
| `Button` variant="ghost" | Detail "back" | `<Link>` styled or a DS Button wrapping the link |
|
||||
|
||||
**No DS component extensions required.** `DataTable.render` already accepts `ReactNode`.
|
||||
|
||||
---
|
||||
|
||||
## Outcome badge mapping
|
||||
|
||||
```ts
|
||||
// utils/upsell/outcomeDisplay.ts (new small utility, importable from both views)
|
||||
import type { Database } from '@/utils/supabase/database.types';
|
||||
|
||||
type UpsellState = Database['public']['Enums']['upsell_state'];
|
||||
|
||||
export const OUTCOME_BADGE: Record<UpsellState, { variant: 'success' | 'danger' | 'info' | 'solid'; label: string }> = {
|
||||
accepted: { variant: 'success', label: 'Accepted' },
|
||||
declined_hard: { variant: 'danger', label: 'Declined' },
|
||||
declined_soft: { variant: 'danger', label: 'Declined (soft)' },
|
||||
no_pitch: { variant: 'info', label: 'No Pitch' },
|
||||
no_offer: { variant: 'info', label: 'No Offer' },
|
||||
in_progress: { variant: 'solid', label: 'In Progress' },
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Actions (add to `actions.ts`)
|
||||
|
||||
### `listMyTickets()`
|
||||
|
||||
```ts
|
||||
export type TicketWithGaid = TicketRow & { clients: { gaid: string | null } | null };
|
||||
|
||||
export async function listMyTickets(): Promise<GemResult<TicketWithGaid[]>> {
|
||||
const current = await getCurrentUserWithProfile();
|
||||
if (!current) return { ok: false, error: 'Not authenticated.' };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('upsell_tickets')
|
||||
.select('*, clients(gaid)')
|
||||
.eq('created_by', current.user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) return { ok: false, error: 'Could not load history.' };
|
||||
return { ok: true, data: (data ?? []) as TicketWithGaid[] };
|
||||
}
|
||||
```
|
||||
|
||||
### `getTicketById(id)`
|
||||
|
||||
```ts
|
||||
export async function getTicketById(id: string): Promise<GemResult<TicketWithGaid>> {
|
||||
const current = await getCurrentUserWithProfile();
|
||||
if (!current) return { ok: false, error: 'Not authenticated.' };
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('upsell_tickets')
|
||||
.select('*, clients(gaid)')
|
||||
.eq('id', id)
|
||||
.eq('created_by', current.user.id) // defense-in-depth (RLS also filters)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) return { ok: false, error: 'Could not load ticket.' };
|
||||
if (!data) return { ok: false, error: 'Ticket not found.' };
|
||||
return { ok: true, data: data as TicketWithGaid };
|
||||
}
|
||||
```
|
||||
|
||||
RLS already guarantees `created_by = auth.uid()` — the explicit `.eq` is defense-in-depth.
|
||||
|
||||
---
|
||||
|
||||
## `SessionHistoryView.tsx` — key details
|
||||
|
||||
**DataTable columns** (7 total — matches the Penpot mockup):
|
||||
|
||||
```ts
|
||||
const COLUMNS: TDataColumn[] = [
|
||||
{ key: 'created_at', header: 'Date', render: (r) => formatDate(r.created_at as string) },
|
||||
{ key: 'case_id', header: 'Case ID', render: (r) => (r.case_id as string) ?? '—' },
|
||||
{ key: 'gaid', header: 'GAID', render: (r) => truncateGaid(r.clients?.gaid ?? null) },
|
||||
{ key: 'state', header: 'Outcome', render: (r) => {
|
||||
const { variant, label } = OUTCOME_BADGE[r.state as UpsellState];
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
}},
|
||||
{ key: 'plan_sold', header: 'Plan Sold', render: (r) => formatPlan(r.plan_sold as string | null) },
|
||||
{ key: 'initiation_type', header: 'Type', render: (r) => formatInitiationType(r.initiation_type as string | null) },
|
||||
{ key: 'id', header: '', align: 'right', render: (r) => (
|
||||
<Link href={`/upsell-evaluator/history/${r.id}`} className={styles.viewLink}>View →</Link>
|
||||
)},
|
||||
];
|
||||
```
|
||||
|
||||
**Display helpers** (co-locate in `_history/SessionHistoryView.tsx` or extract to `utils/upsell/outcomeDisplay.ts`):
|
||||
|
||||
```ts
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// Show first 18 chars + ellipsis so the column doesn't blow out
|
||||
const truncateGaid = (gaid: string | null) => gaid ? gaid.slice(0, 18) + '…' : '—';
|
||||
|
||||
// '2_gig' → '2 Gig', null → '—'
|
||||
const formatPlan = (plan: string | null) =>
|
||||
plan ? plan.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) : '—';
|
||||
|
||||
// 'agent_initiated' → 'Agent', 'customer_initiated' → 'Customer', null → '—'
|
||||
const formatInitiationType = (t: string | null) =>
|
||||
t === 'agent_initiated' ? 'Agent' : t === 'customer_initiated' ? 'Customer' : '—';
|
||||
|
||||
// 'internet_wifi' → 'Internet / Wi-Fi' (best-effort title-case with slash)
|
||||
const formatIssueType = (t: string | null) =>
|
||||
t ? t.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') : '—';
|
||||
```
|
||||
|
||||
**SearchField** — controlled `useState`, filters `data` client-side:
|
||||
```ts
|
||||
const filtered = tickets.filter(t =>
|
||||
(t.case_id ?? '').includes(query) ||
|
||||
(t.clients?.gaid ?? '').toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
```
|
||||
|
||||
**AgentStatCard grid (3 cards) — reuse `countStates` + `formatRate` from `utils/dashboard/metrics.ts`:**
|
||||
|
||||
```ts
|
||||
import { countStates, formatRate } from '@/utils/dashboard/metrics';
|
||||
// countStates is not exported yet — either export it or inline the same logic:
|
||||
// offers = accepted + declined_hard + declined_soft
|
||||
// conversionRate = accepted / offers (0 when offers === 0)
|
||||
// All definitions match utils/dashboard/metrics.ts exactly to avoid divergence.
|
||||
|
||||
const counts = countStates(tickets.map(t => t.state));
|
||||
// counts.totalInteractions, counts.offers, counts.accepted, counts.conversionRate
|
||||
```
|
||||
|
||||
Cards:
|
||||
- **Total Sessions** → `counts.totalInteractions`, `tone="neutral"`, sublabel `"All time"`
|
||||
- **Accepted** → `counts.accepted`, `tone="success"`, sublabel `"${counts.accepted} of ${counts.offers} offers"`
|
||||
- **Conversion Rate** → `formatRate(counts.conversionRate)`, `tone={counts.conversionRate > 0 ? "success" : "neutral"}`, sublabel `"Accepted / offers made"`
|
||||
|
||||
> **Note:** `countStates` is currently unexported from `utils/dashboard/metrics.ts`. Either export it (`export function countStates`) as part of this ticket, or inline the identical logic in a co-located helper. Exporting is preferred — it avoids divergence.
|
||||
|
||||
**Empty state:** when `tickets.length === 0`, render a friendly message inside the DataTable area ("No sessions yet — start an evaluation to see your history here.").
|
||||
|
||||
---
|
||||
|
||||
## `TicketDetailView.tsx` — key details
|
||||
|
||||
### `[id]/page.tsx` — Server Component
|
||||
|
||||
```tsx
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getTicketById } from '../actions';
|
||||
import { TicketDetailView } from '../_history/TicketDetailView';
|
||||
|
||||
export default async function TicketDetailPage({ params }: { params: { id: string } }) {
|
||||
const result = await getTicketById(params.id);
|
||||
if (!result.ok) notFound(); // triggers Next.js built-in 404 page
|
||||
return <TicketDetailView ticket={result.data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### `TicketDetailView.tsx` — layout (matches Penpot mockup)
|
||||
|
||||
The detail view uses a **2-column grid** (not stacked), mirroring the mockup:
|
||||
|
||||
```
|
||||
PageHeader
|
||||
title="Session Detail"
|
||||
badge=<Badge variant={outcome.variant}>{outcome.label}</Badge>
|
||||
actions=<Link href="/upsell-evaluator/history">← Back to History</Link>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 mt-6">
|
||||
|
||||
{/* LEFT column */}
|
||||
<div className="flex flex-col gap-5">
|
||||
Card title="Identification"
|
||||
DataRow label="Date" value={formatDate(ticket.created_at)}
|
||||
DataRow label="Case ID" value={ticket.case_id ?? '—'}
|
||||
DataRow label="Order ID" value={ticket.order_id ?? '—'}
|
||||
DataRow label="GAID" value={ticket.clients?.gaid ?? '—'}
|
||||
|
||||
Card title="Outcome"
|
||||
DataRow label="Status" value=<Badge variant={...}>{label}</Badge>
|
||||
DataRow label="Plan Sold" value={formatPlan(ticket.plan_sold)}
|
||||
DataRow label="Recommended" value={formatPlan(ticket.recommended_plan)}
|
||||
DataRow label="Initiation" value={formatInitiationType(ticket.initiation_type)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT column */}
|
||||
<div>
|
||||
Card title="Call Details"
|
||||
DataRow label="Issue Type" value={formatIssueType(ticket.issue_type)}
|
||||
DataRow label="Open Tech Ticket" value={ticket.open_tech_ticket ? 'Yes' : 'No'}
|
||||
DataRow label="Hard Stops" value={ticket.hard_stop_reasons ?? 'None'}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
```
|
||||
|
||||
Reuse the same display helpers (`formatDate`, `formatPlan`, `formatInitiationType`, `formatIssueType`) defined in `outcomeDisplay.ts` — import from there, don't duplicate.
|
||||
|
||||
---
|
||||
|
||||
## Navigation entry point
|
||||
|
||||
Add one link to `components/NavBar.tsx` (visible to all authenticated users):
|
||||
|
||||
```tsx
|
||||
<Link href="/upsell-evaluator/history" className={linkClass}>
|
||||
History
|
||||
</Link>
|
||||
```
|
||||
|
||||
Place after the "Upsell Evaluator" link, before "My Metrics".
|
||||
|
||||
---
|
||||
|
||||
## Tests (TDD — required before PR)
|
||||
|
||||
### Server Actions
|
||||
- `__tests__/upsell/listMyTickets.test.ts`
|
||||
- Returns tickets ordered by `created_at` DESC for the current user
|
||||
- Returns empty array when user has no tickets
|
||||
- Returns `{ ok: false }` when unauthenticated
|
||||
- `__tests__/upsell/getTicketById.test.ts`
|
||||
- Returns ticket when found and owned by current user
|
||||
- Returns `{ ok: false, error: 'Ticket not found.' }` when ticket belongs to another user (RLS)
|
||||
- Returns `{ ok: false }` when unauthenticated
|
||||
|
||||
### Components
|
||||
- `__tests__/components/SessionHistoryView.test.tsx`
|
||||
- Renders correct row count from `tickets` prop
|
||||
- SearchField filters rows by Case ID
|
||||
- SearchField filters rows by GAID
|
||||
- Shows empty state when `tickets` is empty
|
||||
- AgentStatCards show correct computed values
|
||||
- `__tests__/components/TicketDetailView.test.tsx`
|
||||
- Renders all DataRow fields present in the ticket
|
||||
- Back link points to `/upsell-evaluator/history`
|
||||
- `Badge` variant matches the ticket `state`
|
||||
|
||||
---
|
||||
|
||||
## Execution — Background Agent
|
||||
|
||||
Esta implementación la ejecutará un agente autónomo de background via la skill global `background-orchestrator`.
|
||||
|
||||
**Instrucciones de lanzamiento:**
|
||||
```
|
||||
lanza un agente para esto → GFIBER-622 Session History
|
||||
```
|
||||
|
||||
**Nombre del agente sugerido:** `agente-gfiber-622-session-history`
|
||||
|
||||
**Rama:** `feat/gfiber-622-session-history` (desde `dev`, PR apunta a `dev`)
|
||||
|
||||
**Alcance delegado al agente:**
|
||||
1. Exportar `countStates` en `utils/dashboard/metrics.ts`
|
||||
2. Crear `utils/upsell/outcomeDisplay.ts` con los helpers de display
|
||||
3. Añadir `listMyTickets()` + `getTicketById()` en `actions.ts`
|
||||
4. Crear `app/(protected)/upsell-evaluator/history/page.tsx`
|
||||
5. Crear `app/(protected)/upsell-evaluator/history/[id]/page.tsx` (con `notFound()`)
|
||||
6. Crear `app/(protected)/upsell-evaluator/_history/SessionHistoryView.tsx`
|
||||
7. Crear `app/(protected)/upsell-evaluator/_history/TicketDetailView.tsx`
|
||||
8. Modificar `components/NavBar.tsx` — agregar link "History"
|
||||
9. Escribir todos los tests **antes** de cada implementación (TDD)
|
||||
10. Abrir PR via `aleleba-pr` al terminar con CI verde
|
||||
|
||||
**El agente nunca mergea.** La aprobación y merge quedan a decisión del usuario.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Server Actions**: `npm run test` — all new tests pass, no regressions (638+ green).
|
||||
2. **History page** (`/upsell-evaluator/history`):
|
||||
- Loads with sessions listed for the logged-in user only (test with two accounts)
|
||||
- Search by Case ID and GAID works
|
||||
- AgentStatCards show correct counts
|
||||
- "View →" link navigates to the detail page
|
||||
3. **Detail page** (`/upsell-evaluator/history/[id]`):
|
||||
- All fields render correctly
|
||||
- "Back to History" link returns to the list
|
||||
- Attempting to access another user's ticket ID returns a 404/redirect (RLS)
|
||||
4. **NavBar**: "History" link appears for regular users (not admin-gated).
|
||||
5. `npm run build` — no TypeScript errors.
|
||||
Reference in New Issue
Block a user