feat(inquiries): triage workflow on the inbox (R2-M2)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user