From f3143d7561aa3707ccb88f251108263016b38339 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 23:48:59 +0200 Subject: [PATCH] feat(inquiries): triage workflow on the inbox (R2-M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inquiry inbox was read-only — every inquiry stayed there forever with no way to mark "I handled this" or "this is spam." Now: - Migration 0045 adds triage_state ('open' | 'assigned' | 'converted' | 'dismissed' default 'open') + triaged_at + triaged_by columns to website_submissions, plus a (port_id, triage_state, received_at) index for the inbox query. - New PATCH /api/v1/admin/website-submissions/[id]/triage flips the state with audit log entry. - List endpoint takes a `state` filter (default 'inbox' = open + assigned, hides converted + dismissed). - UI: per-row Convert / Assign / Dismiss / Reopen actions; second filter row for state; triage badge per card. "Convert" jumps to /clients with prefill_name / prefill_email / prefill_phone / prefill_source / prefill_inquiry_id query params + marks the row converted (the client-create form will read those — same prefill pattern other entry points use). 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../website-submissions/[id]/triage/route.ts | 66 +++++++++ .../api/v1/admin/website-submissions/route.ts | 11 +- src/components/admin/inquiry-inbox.tsx | 140 +++++++++++++++++- .../0045_website_submissions_triage.sql | 18 +++ src/lib/db/schema/website-submissions.ts | 10 ++ 5 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 src/app/api/v1/admin/website-submissions/[id]/triage/route.ts create mode 100644 src/lib/db/migrations/0045_website_submissions_triage.sql diff --git a/src/app/api/v1/admin/website-submissions/[id]/triage/route.ts b/src/app/api/v1/admin/website-submissions/[id]/triage/route.ts new file mode 100644 index 0000000..6fa717b --- /dev/null +++ b/src/app/api/v1/admin/website-submissions/[id]/triage/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { and, eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; +import { createAuditLog } from '@/lib/audit'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +const bodySchema = z.object({ + state: z.enum(['open', 'assigned', 'converted', 'dismissed']), +}); + +/** + * Mutate the triage state of a single inquiry. Used by the inbox row + * actions: Mark assigned / Mark resolved (converted) / Dismiss. + * + * The 'converted' state is set automatically by the new-client-from- + * inquiry flow once the operator submits the prefilled form; this + * endpoint accepts it explicitly too so an operator can mark + * already-handled submissions caught up retrospectively. + */ +export const PATCH = withAuth( + withPermission('admin', 'view_audit_log', async (req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('submission'); + + const { state } = await parseBody(req, bodySchema); + + const [updated] = await db + .update(websiteSubmissions) + .set({ + triageState: state, + triagedAt: new Date(), + triagedBy: ctx.userId, + }) + .where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, ctx.portId))) + .returning({ + id: websiteSubmissions.id, + triageState: websiteSubmissions.triageState, + triagedAt: websiteSubmissions.triagedAt, + }); + + if (!updated) throw new NotFoundError('submission'); + + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'website_submission', + entityId: id, + fieldChanged: 'triageState', + newValue: { triageState: state }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/website-submissions/route.ts b/src/app/api/v1/admin/website-submissions/route.ts index eddde2b..3193825 100644 --- a/src/app/api/v1/admin/website-submissions/route.ts +++ b/src/app/api/v1/admin/website-submissions/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { and, desc, eq, lt, sql, type SQL } from 'drizzle-orm'; +import { and, desc, eq, inArray, lt, sql, type SQL } from 'drizzle-orm'; import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; @@ -11,6 +11,10 @@ import { errorResponse } from '@/lib/errors'; const querySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']).optional(), + /** Default 'inbox' (open + assigned) so resolved/dismissed roll off + * the active queue. Pass 'all' to surface every row including + * history. */ + state: z.enum(['inbox', 'open', 'assigned', 'converted', 'dismissed', 'all']).default('inbox'), cursorAt: z.string().optional(), cursorId: z.string().optional(), }); @@ -21,6 +25,11 @@ export const GET = withAuth( const query = parseQuery(req, querySchema); const conds: SQL[] = [eq(websiteSubmissions.portId, ctx.portId)]; if (query.kind) conds.push(eq(websiteSubmissions.kind, query.kind)); + if (query.state === 'inbox') { + conds.push(inArray(websiteSubmissions.triageState, ['open', 'assigned'])); + } else if (query.state !== 'all') { + conds.push(eq(websiteSubmissions.triageState, query.state)); + } if (query.cursorAt && query.cursorId) { const cursorAt = new Date(query.cursorAt).toISOString(); conds.push( diff --git a/src/components/admin/inquiry-inbox.tsx b/src/components/admin/inquiry-inbox.tsx index 8648482..7c1406a 100644 --- a/src/components/admin/inquiry-inbox.tsx +++ b/src/components/admin/inquiry-inbox.tsx @@ -1,8 +1,10 @@ 'use client'; import { useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useParams, useRouter } from 'next/navigation'; import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; import { PageHeader } from '@/components/shared/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -10,6 +12,9 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; +type TriageState = 'open' | 'assigned' | 'converted' | 'dismissed'; +type StateFilter = 'inbox' | 'open' | 'assigned' | 'converted' | 'dismissed' | 'all'; + interface Submission { id: string; portId: string; @@ -20,8 +25,18 @@ interface Submission { sourceIp: string | null; userAgent: string | null; receivedAt: string; + triageState: TriageState; + triagedAt: string | null; + triagedBy: string | null; } +const TRIAGE_BADGE: Record = { + open: 'bg-blue-100 text-blue-800', + assigned: 'bg-amber-100 text-amber-900', + converted: 'bg-emerald-100 text-emerald-800', + dismissed: 'bg-slate-100 text-slate-600', +}; + interface ListResponse { data: Submission[]; pagination: { nextCursor: { receivedAt: string; id: string } | null }; @@ -64,14 +79,21 @@ function pickPhone(payload: Record | null): string { export function InquiryInbox() { const [kind, setKind] = useState('all'); + const [state, setState] = useState('inbox'); const [expanded, setExpanded] = useState(null); + const qc = useQueryClient(); + const router = useRouter(); + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; const { data, isLoading, error } = useQuery({ - queryKey: ['inquiry-inbox', kind], - queryFn: () => - apiFetch( - `/api/v1/admin/website-submissions${kind === 'all' ? '' : `?kind=${kind}`}`, - ), + queryKey: ['inquiry-inbox', kind, state], + queryFn: () => { + const qs = new URLSearchParams(); + if (kind !== 'all') qs.set('kind', kind); + qs.set('state', state); + return apiFetch(`/api/v1/admin/website-submissions?${qs}`); + }, }); const counts = data?.counts ?? {}; @@ -79,6 +101,39 @@ export function InquiryInbox() { const rows = data?.data ?? []; + const triageMutation = useMutation({ + mutationFn: (args: { id: string; state: TriageState }) => + apiFetch(`/api/v1/admin/website-submissions/${args.id}/triage`, { + method: 'PATCH', + body: { state: args.state }, + }), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ['inquiry-inbox'] }); + toast.success(`Marked ${vars.state}.`); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Triage update failed'); + }, + }); + + function convertToClient(row: Submission) { + // Mark converted then jump to /clients with prefilled query params. + // The /clients page reads ?prefill_* on mount and opens the New + // Client form with the values populated. Final form submission is + // entirely operator-driven; this just hands the data over. + const name = pickName(row.payload); + const email = pickEmail(row.payload); + const phone = pickPhone(row.payload); + triageMutation.mutate({ id: row.id, state: 'converted' }); + const qs = new URLSearchParams(); + if (name) qs.set('prefill_name', name); + if (email) qs.set('prefill_email', email); + if (phone) qs.set('prefill_phone', phone); + qs.set('prefill_source', 'website'); + qs.set('prefill_inquiry_id', row.id); + router.push(`/${portSlug}/clients?${qs.toString()}`); + } + return (
setKind('all')} /> @@ -109,6 +164,32 @@ export function InquiryInbox() { />
+
+ State: + setState('inbox')} + /> + setState('open')} /> + setState('assigned')} + /> + setState('converted')} + /> + setState('dismissed')} + /> + setState('all')} /> +
+
{isLoading ? (

Loading…

@@ -135,8 +216,9 @@ export function InquiryInbox() {
-
+
{KIND_LABELS[row.kind]} + {row.triageState}
+ + {row.triageState !== 'converted' && row.triageState !== 'dismissed' && ( +
+ + {row.triageState === 'open' && ( + + )} + +
+ )} + {(row.triageState === 'converted' || row.triageState === 'dismissed') && ( +
+ +
+ )} {isOpen && ( diff --git a/src/lib/db/migrations/0045_website_submissions_triage.sql b/src/lib/db/migrations/0045_website_submissions_triage.sql new file mode 100644 index 0000000..f1d97e9 --- /dev/null +++ b/src/lib/db/migrations/0045_website_submissions_triage.sql @@ -0,0 +1,18 @@ +-- Inquiry-inbox triage workflow. Adds three columns to +-- website_submissions so the inbox isn't permanent dead weight: +-- +-- triage_state: 'open' | 'converted' | 'dismissed' | 'assigned' +-- triaged_at: timestamptz when the state last changed +-- triaged_by: user id who set the state +-- +-- Default 'open' so backfill leaves history visible. The default +-- inbox query filters to open + assigned so resolved/dismissed roll +-- off without being permanently lost. + +ALTER TABLE website_submissions + ADD COLUMN IF NOT EXISTS triage_state text NOT NULL DEFAULT 'open', + ADD COLUMN IF NOT EXISTS triaged_at timestamptz, + ADD COLUMN IF NOT EXISTS triaged_by text; + +CREATE INDEX IF NOT EXISTS idx_ws_triage_state + ON website_submissions (port_id, triage_state, received_at DESC); diff --git a/src/lib/db/schema/website-submissions.ts b/src/lib/db/schema/website-submissions.ts index ef8a319..5bb54ae 100644 --- a/src/lib/db/schema/website-submissions.ts +++ b/src/lib/db/schema/website-submissions.ts @@ -55,11 +55,21 @@ export const websiteSubmissions = pgTable( sourceIp: text('source_ip'), userAgent: text('user_agent'), receivedAt: timestamp('received_at', { withTimezone: true }).notNull().defaultNow(), + /** Triage workflow state. Default 'open'; transitions to + * 'converted' (operator created a client/interest from this row), + * 'dismissed' (operator marked as not actionable), or 'assigned' + * (operator opened it but hasn't resolved yet). The inbox default + * query filters to open + assigned. */ + triageState: text('triage_state').notNull().default('open'), + triagedAt: timestamp('triaged_at', { withTimezone: true }), + /** better-auth user id of the operator who last changed triage_state. */ + triagedBy: text('triaged_by'), }, (table) => [ uniqueIndex('idx_ws_submission_id').on(table.submissionId), index('idx_ws_port_received').on(table.portId, table.receivedAt), index('idx_ws_kind').on(table.kind), + index('idx_ws_triage_state').on(table.portId, table.triageState, table.receivedAt), ], );