import { NextRequest, NextResponse } from 'next/server'; import { sql } from 'drizzle-orm'; import { withAuth } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { errorResponse } from '@/lib/errors'; import { getRecentlyViewed, trackView } from '@/lib/services/recently-viewed.service'; import { trackViewSchema } from '@/lib/validators/search'; /** * Hydrated row returned by GET — contains the same `type` + `id` from * the Redis sorted set, plus the labels needed to render the row in the * dropdown ("Client · Jane Smith") without an extra round-trip. * * Rows whose underlying entity has been hard-deleted are filtered out * (Redis keeps the membership; the hydration JOIN drops the orphan). */ interface HydratedRow { type: string; id: string; label: string; sub: string | null; href: string; viewedAt: number; } /** Batch-resolve labels for a list of (type, id) pairs grouped by type. */ async function hydrate( portSlug: string, portId: string, pairs: Array<{ type: string; id: string; viewedAt: number }>, ): Promise { if (pairs.length === 0) return []; const byType = new Map>(); for (const p of pairs) { if (!byType.has(p.type)) byType.set(p.type, []); byType.get(p.type)!.push({ id: p.id, viewedAt: p.viewedAt }); } const rows: HydratedRow[] = []; await Promise.all( Array.from(byType.entries()).map(async ([type, items]) => { const ids = items.map((i) => i.id); const idVal = sql`(${sql.join( ids.map((id) => sql`${id}`), sql`, `, )})`; const idAt = new Map(items.map((i) => [i.id, i.viewedAt] as const)); switch (type) { case 'client': { const dbRows = await db.execute<{ id: string; full_name: string }>(sql` SELECT id, full_name FROM clients WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.full_name, sub: 'Client', href: `/${portSlug}/clients/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'residential-client': { const dbRows = await db.execute<{ id: string; full_name: string }>(sql` SELECT id, full_name FROM residential_clients WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.full_name, sub: 'Residential client', href: `/${portSlug}/residential/clients/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'yacht': { const dbRows = await db.execute<{ id: string; name: string; hull_number: string | null }>( sql`SELECT id, name, hull_number FROM yachts WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}`, ); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.name, sub: r.hull_number ? `Yacht · ${r.hull_number}` : 'Yacht', href: `/${portSlug}/yachts/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'company': { const dbRows = await db.execute<{ id: string; name: string }>( sql`SELECT id, name FROM companies WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}`, ); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.name, sub: 'Company', href: `/${portSlug}/companies/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'interest': { const dbRows = await db.execute<{ id: string; full_name: string; mooring_number: string | null; }>(sql` SELECT i.id, c.full_name, b.mooring_number FROM interests i JOIN clients c ON i.client_id = c.id LEFT JOIN interest_berths ib ON ib.interest_id = i.id AND ib.is_primary = true LEFT JOIN berths b ON ib.berth_id = b.id WHERE i.port_id = ${portId} AND i.archived_at IS NULL AND i.id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.full_name, sub: r.mooring_number ? `Interest · ${r.mooring_number}` : 'Interest', href: `/${portSlug}/interests/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'residential-interest': { const dbRows = await db.execute<{ id: string; full_name: string }>(sql` SELECT ri.id, rc.full_name FROM residential_interests ri JOIN residential_clients rc ON ri.residential_client_id = rc.id WHERE ri.port_id = ${portId} AND ri.archived_at IS NULL AND ri.id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.full_name, sub: 'Residential interest', href: `/${portSlug}/residential/interests/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'berth': { const dbRows = await db.execute<{ id: string; mooring_number: string }>(sql` SELECT id, mooring_number FROM berths WHERE port_id = ${portId} AND id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.mooring_number, sub: 'Berth', href: `/${portSlug}/berths/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'invoice': { const dbRows = await db.execute<{ id: string; invoice_number: string; client_name: string; }>(sql` SELECT id, invoice_number, client_name FROM invoices WHERE port_id = ${portId} AND id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.invoice_number, sub: `Invoice · ${r.client_name}`, href: `/${portSlug}/invoices/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'expense': { const dbRows = await db.execute<{ id: string; description: string | null; establishment_name: string | null; }>(sql` SELECT id, description, establishment_name FROM expenses WHERE port_id = ${portId} AND id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.description ?? r.establishment_name ?? 'Expense', sub: 'Expense', href: `/${portSlug}/expenses/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } case 'document': { const dbRows = await db.execute<{ id: string; title: string }>(sql` SELECT id, title FROM documents WHERE port_id = ${portId} AND id IN ${idVal} `); for (const r of Array.from(dbRows)) { rows.push({ type, id: r.id, label: r.title, sub: 'Document', href: `/${portSlug}/documents/${r.id}`, viewedAt: idAt.get(r.id) ?? 0, }); } return; } } }), ); // Re-sort by viewedAt DESC since the per-type queries lose the order. rows.sort((a, b) => b.viewedAt - a.viewedAt); return rows; } export const GET = withAuth(async (req: NextRequest, ctx) => { try { const limitParam = req.nextUrl.searchParams.get('limit'); const limit = limitParam ? Math.min(Math.max(Number(limitParam), 1), 20) : 10; const pairs = await getRecentlyViewed(ctx.userId, ctx.portId, limit); const items = await hydrate(ctx.portSlug, ctx.portId, pairs); return NextResponse.json({ items }); } catch (error) { return errorResponse(error); } }); export const POST = withAuth(async (req: NextRequest, ctx) => { try { const body = await req.json(); const parsed = trackViewSchema.parse(body); trackView(ctx.userId, ctx.portId, parsed.type, parsed.id); return NextResponse.json({ ok: true }); } catch (error) { return errorResponse(error); } });