8 Commits

Author SHA1 Message Date
4d018be800 feat(inquiries): one-off NocoDB historical contact-form import (idempotent, dry-run default)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:28:01 +02:00
95d7776bb6 test(inquiries): drop unused import 2026-06-17 18:25:13 +02:00
0cc05f302f feat(inquiries): top-level Inquiries page (list + detail + convert), nav entries; retire admin inbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:23:13 +02:00
54554a0928 feat(inquiries): list/get/triage/convert service + API routes (find-or-create client)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:09:59 +02:00
9879b82e5f feat(inquiries): website_submissions tracking + display columns; capture populates contact name/email
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:03:47 +02:00
08adb4aeea feat(permissions): add inquiries resource (view/manage) + idempotent role backfill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:59:32 +02:00
6c4490f653 feat(alerts): always-visible dismiss/ack actions + Dismiss all (service, endpoint, UI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:53:12 +02:00
13efe177a5 feat(alerts): split interest.stale into worked-then-quiet + new-untouched (interest.no_activity)
- interest.stale now fires only for interests with real in-system follow-up
  (contact log / note / update audit) that went quiet 14+ days.
- new interest.no_activity rule covers never-touched, non-imported interests.
- guard interest.high_value_silent against imported-untouched hot leads.
- keys off migration_source_links ledger to identify the bulk import, so the
  imported backlog matches neither rule and the engine auto-resolves the flood.
- test teardown: delete interest_contact_log + test migration ledger rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:49:22 +02:00
40 changed files with 2015 additions and 46 deletions

View File

@@ -0,0 +1,176 @@
/**
* One-off import of historical "Website Contact Form Submissions" from NocoDB
* into the CRM `website_submissions` table, so they show up in the Inquiries
* workbench alongside post-cutover submissions.
*
* The cutover migration imported interests / residential / berths / expenses but
* NOT the contact-form table — those general contact-page inquiries (the
* "broker"/"investor"/"owner" enquiries) were left behind in NocoDB.
*
* Idempotent: each row maps to a deterministic `submission_id`
* (`nocodb-cf-<id>`) guarded by the unique index, plus a `migration_source_links`
* ledger row (`source_system='nocodb_website_submissions'`). Re-running is a
* no-op for already-imported rows.
*
* Usage:
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts # dry-run
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply # write
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply --port-slug port-nimara
*
* Requires NOCODB_URL + NOCODB_TOKEN in env (same as the migration). Writes to
* whatever DATABASE_URL points at — point it at prod ONLY with explicit approval.
*/
import 'dotenv/config';
import { eq } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import {
loadNocoDbConfig,
fetchAllRows,
NOCO_TABLES,
type NocoDbRow,
} from '@/lib/dedup/nocodb-source';
const SOURCE_SYSTEM = 'nocodb_website_submissions';
const APPLIED_ID = 'import-website-inquiries';
function arg(name: string): string | undefined {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
if (hit) return hit.split('=')[1];
const idx = process.argv.indexOf(`--${name}`);
if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1]!.startsWith('--')) {
return process.argv[idx + 1];
}
return undefined;
}
function str(row: NocoDbRow, ...keys: string[]): string {
for (const k of keys) {
const v = row[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
}
function parseDate(row: NocoDbRow): Date {
const raw = str(row, 'CreatedAt', 'created_at', 'Created At', 'createdAt');
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
}
return new Date();
}
async function main() {
const apply = process.argv.includes('--apply');
const portSlug = arg('port-slug') ?? 'port-nimara';
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, portSlug))
.limit(1);
if (!port) throw new Error(`Unknown port slug: ${portSlug}`);
const config = loadNocoDbConfig();
console.log(`[import] Fetching contact-form submissions from NocoDB…`);
const rows = await fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config);
console.log(`[import] Fetched ${rows.length} rows from NocoDB.`);
let inserted = 0;
let skipped = 0;
const samples: Array<Record<string, unknown>> = [];
for (const row of rows) {
const legacyId = String(row.Id);
const submissionId = `nocodb-cf-${legacyId}`;
const fullName = str(row, 'Full Name', 'Name', 'full_name');
const email = str(row, 'Email Address', 'Email', 'email');
const interest = str(row, 'Type of Interest', 'interest');
const comments = str(row, 'Comments', 'comments');
const receivedAt = parseDate(row);
const payload = {
name: fullName,
email,
interest,
comments,
imported_from: 'nocodb_contact_form',
legacy_nocodb_id: legacyId,
};
if (samples.length < 3) {
samples.push({
submissionId,
fullName,
email,
interest,
receivedAt: receivedAt.toISOString(),
});
}
if (!apply) {
// Dry-run: count how many are not yet present.
const [existing] = await db
.select({ id: websiteSubmissions.id })
.from(websiteSubmissions)
.where(eq(websiteSubmissions.submissionId, submissionId))
.limit(1);
if (existing) skipped += 1;
else inserted += 1;
continue;
}
const result = await db
.insert(websiteSubmissions)
.values({
portId: port.id,
submissionId,
kind: 'contact_form',
payload,
contactName: fullName || null,
contactEmail: email || null,
legacyNocodbId: legacyId,
receivedAt,
triageState: 'open',
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });
if (result[0]) {
inserted += 1;
await db
.insert(migrationSourceLinks)
.values({
sourceSystem: SOURCE_SYSTEM,
sourceId: legacyId,
targetEntityType: 'website_submission',
targetEntityId: result[0].id,
appliedId: APPLIED_ID,
})
.onConflictDoNothing();
} else {
skipped += 1;
}
}
console.log('\n[import] Sample rows:');
for (const s of samples) console.log(' ', JSON.stringify(s));
console.log(
`\n[import] ${apply ? 'APPLIED' : 'DRY-RUN'} — port=${portSlug}: ${inserted} ${
apply ? 'inserted' : 'would insert'
}, ${skipped} skipped (already present).`,
);
if (!apply) console.log('[import] Re-run with --apply to write these rows.');
await closeDb();
}
main().catch((err) => {
console.error('[import] FAILED:', err);
process.exitCode = 1;
});

View File

@@ -1,5 +1,15 @@
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
import { redirect } from 'next/navigation';
export default function InquiriesPage() {
return <InquiryInbox />;
/**
* The inquiry inbox is now a top-level, permission-gated page at
* `/[portSlug]/inquiries` (resource `inquiries`), no longer admin-only.
* Redirect the legacy admin URL so old bookmarks/links still land.
*/
interface AdminInquiriesRedirectProps {
params: Promise<{ portSlug: string }>;
}
export default async function AdminInquiriesRedirect({ params }: AdminInquiriesRedirectProps) {
const { portSlug } = await params;
redirect(`/${portSlug}/inquiries`);
}

View File

@@ -0,0 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { InquiryDetail } from '@/components/inquiries/inquiry-detail';
interface InquiryDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function InquiryDetailPage({ params }: InquiryDetailPageProps) {
const { id } = await params;
return <InquiryDetail id={id} />;
}

View File

@@ -0,0 +1,5 @@
import { InquiryList } from '@/components/inquiries/inquiry-list';
export default function InquiriesPage() {
return <InquiryList />;
}

View File

@@ -25,6 +25,7 @@ import {
autoPromoteWebsiteBerthInquiry,
isWebsiteBerthAutopromoteEnabled,
} from '@/lib/services/website-intake-promote.service';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
/**
* POST /api/public/website-inquiries
@@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
// hits, `returning()` yields zero rows and we look up the existing row to
// return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup.
// Extract contact name/email into real columns so the inquiry list can
// search/sort/display without digging into the JSONB payload per row.
const fields = extractInquiryFields(parsed.payload);
const insertResult = await db
.insert(websiteSubmissions)
.values({
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
kind: parsed.kind,
payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
contactName: fields.fullName || null,
contactEmail: fields.email || null,
sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null,
utmSource: parsed.utm_source ?? null,

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { ALERT_RULES } from '@/lib/db/schema/insights';
import { dismissAllForPort } from '@/lib/services/alerts.service';
const bodySchema = z.object({
ruleId: z.enum(ALERT_RULES).optional(),
severity: z.enum(['info', 'warning', 'critical']).optional(),
});
export const POST = withAuth(async (req, ctx) => {
try {
const { ruleId, severity } = await parseBody(req, bodySchema);
const dismissed = await dismissAllForPort(ctx.portId, ctx.userId, { ruleId, severity });
return NextResponse.json({ data: { dismissed } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { convertInquiryToClient, convertInquiryToInterest } from '@/lib/services/inquiries.service';
import { convertInquirySchema } from '@/lib/validators/inquiries';
export const POST = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { target } = await parseBody(req, convertInquirySchema);
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const data =
target === 'interest'
? await convertInquiryToInterest(id, ctx.portId, meta)
: await convertInquiryToClient(id, ctx.portId, meta);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { getInquiryById } from '@/lib/services/inquiries.service';
export const GET = withAuth(
withPermission('inquiries', 'view', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const data = await getInquiryById(id, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { triageInquiry } from '@/lib/services/inquiries.service';
import { triageInquirySchema } from '@/lib/validators/inquiries';
export const PATCH = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { state } = await parseBody(req, triageInquirySchema);
const data = await triageInquiry(id, ctx.portId, state, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listInquiries } from '@/lib/services/inquiries.service';
import { listInquiriesSchema } from '@/lib/validators/inquiries';
export const GET = withAuth(
withPermission('inquiries', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listInquiriesSchema);
const result = await listInquiries(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -103,6 +103,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
delete: false,
change_stage: false,
},
inquiries: {
view: false,
manage: false,
},
};
const GROUP_LABELS: Record<string, string> = {
@@ -126,6 +130,7 @@ const GROUP_LABELS: Record<string, string> = {
admin: 'Administration',
residential_clients: 'Residential Clients',
residential_interests: 'Residential Interests',
inquiries: 'Inquiries',
};
function formatAction(action: string): string {

View File

@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
</div>
</div>
{!readOnly ? (
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
<div className="flex shrink-0 items-start gap-1 text-muted-foreground">
{!acknowledged ? (
<Button
variant="ghost"

View File

@@ -4,10 +4,11 @@ import { useState } from 'react';
import { ShieldAlert } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
import { useAlertCount, useAlertList, useAlertRealtime, useDismissAll } from './use-alerts';
import type { AlertStatus } from './types';
/**
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
const total = count?.total ?? 0;
const alerts = data?.data ?? [];
const dismissAll = useDismissAll();
return (
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
</TabsList>
<TabsContent value={tab} className="mt-4 space-y-2">
{tab === 'open' && alerts.length > 0 ? (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
>
Dismiss all
</Button>
</div>
) : null}
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />

View File

@@ -41,6 +41,15 @@ export function useAlertActions() {
return { acknowledge, dismiss };
}
export function useDismissAll() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (filter: { ruleId?: string; severity?: string } = {}) =>
apiFetch('/api/v1/alerts/dismiss-all', { method: 'POST', body: filter }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }),
});
}
export function useAlertRealtime() {
useRealtimeInvalidation({
'alert:created': [['alerts']],

View File

@@ -0,0 +1,35 @@
'use client';
import Link from 'next/link';
import { formatDistanceToNowStrict } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { KIND_LABELS, TRIAGE_TONE, type InquiryRow } from '@/components/inquiries/inquiry-columns';
export function InquiryCard({ inquiry, portSlug }: { inquiry: InquiryRow; portSlug: string }) {
return (
<Link href={`/${portSlug}/inquiries/${inquiry.id}`} className="block">
<Card className="transition-shadow hover:shadow-sm">
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{inquiry.contactName || '(no name)'}</p>
{inquiry.contactEmail ? (
<p className="truncate text-sm text-muted-foreground">{inquiry.contactEmail}</p>
) : null}
</div>
<Badge className={TRIAGE_TONE[inquiry.triageState]}>{inquiry.triageState}</Badge>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{KIND_LABELS[inquiry.kind]}</span>
<span>·</span>
<span>
{formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
</span>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import Link from 'next/link';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { MoreHorizontal, UserCheck, X, ExternalLink } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export type InquiryKind = 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
export type InquiryTriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
export interface InquiryRow {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
receivedAt: string;
triageState: InquiryTriageState;
convertedClientId: string | null;
convertedInterestId: string | null;
sourceIp: string | null;
utmSource?: string | null;
}
export const KIND_LABELS: Record<InquiryKind, string> = {
berth_inquiry: 'Berth',
residence_inquiry: 'Residence',
contact_form: 'Contact',
};
const KIND_TONE: Record<InquiryKind, string> = {
berth_inquiry: 'bg-blue-100 text-blue-800',
residence_inquiry: 'bg-amber-100 text-amber-900',
contact_form: 'bg-slate-100 text-slate-800',
};
export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
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',
};
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'contactEmail', label: 'Email' },
{ id: 'kind', label: 'Type' },
{ id: 'triageState', label: 'Status' },
{ id: 'utmSource', label: 'UTM source' },
{ id: 'receivedAt', label: 'Received' },
];
export const INQUIRY_DEFAULT_HIDDEN: string[] = ['utmSource'];
interface GetColumnsOptions {
portSlug: string;
onTriage: (row: InquiryRow, state: InquiryTriageState) => void;
}
export function getInquiryColumns({
portSlug,
onTriage,
}: GetColumnsOptions): ColumnDef<InquiryRow, unknown>[] {
return [
{
id: 'contactName',
accessorKey: 'contactName',
header: 'Name',
cell: ({ row }) => (
<Link
href={`/${portSlug}/inquiries/${row.original.id}`}
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.contactName || '(no name)'}
</Link>
),
},
{
id: 'contactEmail',
accessorKey: 'contactEmail',
header: 'Email',
enableSorting: false,
cell: ({ getValue }) => {
const email = getValue() as string | null;
return email ? (
<span className="text-sm">{email}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'kind',
accessorKey: 'kind',
header: 'Type',
cell: ({ getValue }) => {
const kind = getValue() as InquiryKind;
return <Badge className={KIND_TONE[kind]}>{KIND_LABELS[kind]}</Badge>;
},
},
{
id: 'triageState',
accessorKey: 'triageState',
header: 'Status',
cell: ({ row }) => {
const state = row.original.triageState;
return (
<div className="flex items-center gap-1.5">
<Badge className={TRIAGE_TONE[state]}>{state}</Badge>
{row.original.convertedInterestId ? (
<Link
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
interest
</Link>
) : row.original.convertedClientId ? (
<Link
href={`/${portSlug}/clients/${row.original.convertedClientId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
client
</Link>
) : null}
</div>
);
},
},
{
id: 'utmSource',
accessorKey: 'utmSource',
header: 'UTM source',
enableSorting: false,
cell: ({ getValue }) => {
const utm = getValue() as string | null;
return utm ? (
<span className="text-sm">{utm}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'receivedAt',
accessorKey: 'receivedAt',
header: 'Received',
cell: ({ getValue }) => {
const iso = getValue() as string;
const d = new Date(iso);
return (
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
{formatDistanceToNowStrict(d, { addSuffix: true })}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => {
const isResolved =
row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Row actions for ${row.original.contactName ?? 'inquiry'}`}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/inquiries/${row.original.id}`}>
<ExternalLink className="mr-2 h-3.5 w-3.5" aria-hidden />
Open
</Link>
</DropdownMenuItem>
{!isResolved ? (
<>
<DropdownMenuItem onClick={() => onTriage(row.original, 'assigned')}>
<UserCheck className="mr-2 h-3.5 w-3.5" aria-hidden />
Assign to me
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTriage(row.original, 'dismissed')}>
<X className="mr-2 h-3.5 w-3.5" aria-hidden />
Dismiss
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => onTriage(row.original, 'open')}>
Reopen
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ArrowRight, UserPlus, UserCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface InquiryConvertActionsProps {
portSlug: string;
inquiry: {
id: string;
triageState: string;
convertedClientId: string | null;
convertedInterestId: string | null;
};
}
export function InquiryConvertActions({ portSlug, inquiry }: InquiryConvertActionsProps) {
const router = useRouter();
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
};
const convert = useMutation({
mutationFn: (target: 'client' | 'interest') =>
apiFetch<{ data: { clientId: string; interestId: string | null } }>(
`/api/v1/inquiries/${inquiry.id}/convert`,
{ method: 'POST', body: { target } },
),
onSuccess: (res, target) => {
invalidate();
if (target === 'interest' && res.data.interestId) {
toast.success('Converted to interest.');
router.push(`/${portSlug}/interests/${res.data.interestId}`);
} else {
toast.success('Converted to client.');
router.push(`/${portSlug}/clients/${res.data.clientId}`);
}
},
onError: (err: unknown) => toastError(err, 'Convert failed'),
});
const triage = useMutation({
mutationFn: (state: 'open' | 'assigned' | 'dismissed') =>
apiFetch(`/api/v1/inquiries/${inquiry.id}/triage`, { method: 'PATCH', body: { state } }),
onSuccess: (_d, state) => {
invalidate();
toast.success(`Marked ${state}.`);
},
onError: (err: unknown) => toastError(err, 'Update failed'),
});
const busy = convert.isPending || triage.isPending;
const alreadyInterest = Boolean(inquiry.convertedInterestId);
return (
<PermissionGate resource="inquiries" action="manage">
<div className="flex flex-wrap items-center gap-2">
{alreadyInterest ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/interests/${inquiry.convertedInterestId}`}>View interest</a>
</Button>
) : (
<Button size="sm" disabled={busy} onClick={() => convert.mutate('interest')}>
<ArrowRight className="mr-1.5 h-4 w-4" aria-hidden />
Convert to interest
</Button>
)}
{inquiry.convertedClientId ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/clients/${inquiry.convertedClientId}`}>View client</a>
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => convert.mutate('client')}
>
<UserPlus className="mr-1.5 h-4 w-4" aria-hidden />
Convert to client
</Button>
)}
{inquiry.triageState === 'open' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('assigned')}
>
<UserCheck className="mr-1.5 h-4 w-4" aria-hidden />
Assign to me
</Button>
) : null}
{inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('dismissed')}
>
<X className="mr-1.5 h-4 w-4" aria-hidden />
Dismiss
</Button>
) : null}
</div>
</PermissionGate>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { format } from 'date-fns';
import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import {
KIND_LABELS,
TRIAGE_TONE,
type InquiryKind,
type InquiryTriageState,
} from '@/components/inquiries/inquiry-columns';
import { InquiryConvertActions } from '@/components/inquiries/inquiry-convert-actions';
interface InquiryDetailData {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
payload: Record<string, unknown> | null;
receivedAt: string;
sourceIp: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
triageState: InquiryTriageState;
triagedAt: string | null;
convertedClientId: string | null;
convertedInterestId: string | null;
convertedClient: { id: string; fullName: string } | null;
convertedInterest: { id: string; pipelineStage: string } | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2 py-1.5 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 break-words">
{value || <span className="text-muted-foreground"></span>}
</span>
</div>
);
}
export function InquiryDetail({ id }: { id: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading, error } = useQuery<InquiryDetailData>({
queryKey: ['inquiries', id],
queryFn: () =>
apiFetch<{ data: InquiryDetailData }>(`/api/v1/inquiries/${id}`).then((r) => r.data),
retry: (count, err) => {
const status = (err as { status?: number })?.status;
return status === 404 || status === 403 ? false : count < 2;
},
});
if (error && !isLoading) {
const status = (error as { status?: number })?.status;
return (
<DetailNotFound
entity="inquiry"
backHref={`/${portSlug}/inquiries`}
backLabel="Back to inquiries"
status={status}
/>
);
}
const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
const tabs: DetailTab[] = [
{
id: 'overview',
label: 'Overview',
content: (
<div className="max-w-xl">
<Row label="Name" value={data?.contactName} />
<Row label="Email" value={data?.contactEmail} />
<Row label="Phone" value={str('phone')} />
{data?.kind === 'residence_inquiry' ? (
<Row label="Place of residence" value={str('address')} />
) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{data?.kind === 'contact_form' ? <Row label="Comments" value={str('comments')} /> : null}
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
<Row label="Source IP" value={data?.sourceIp} />
<Row label="UTM source" value={data?.utmSource} />
<Row label="UTM medium" value={data?.utmMedium} />
<Row label="UTM campaign" value={data?.utmCampaign} />
</div>
),
},
{
id: 'tracking',
label: 'Tracking',
content: (
<div className="max-w-xl">
<Row
label="Status"
value={
data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge>
) : (
''
)
}
/>
<Row
label="Triaged at"
value={data?.triagedAt ? format(new Date(data.triagedAt), 'PPpp') : ''}
/>
<Row
label="Converted client"
value={
data?.convertedClient ? (
<a
href={`/${portSlug}/clients/${data.convertedClient.id}`}
className="text-primary hover:underline"
>
{data.convertedClient.fullName}
</a>
) : null
}
/>
<Row
label="Converted interest"
value={
data?.convertedInterest ? (
<a
href={`/${portSlug}/interests/${data.convertedInterest.id}`}
className="text-primary hover:underline"
>
View interest ({data.convertedInterest.pipelineStage})
</a>
) : null
}
/>
</div>
),
},
{
id: 'payload',
label: 'Raw payload',
content: (
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
{JSON.stringify(data?.payload ?? {}, null, 2)}
</pre>
),
},
];
return (
<DetailLayout
isLoading={isLoading}
header={
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
{data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{data ? KIND_LABELS[data.kind] : ''} inquiry
{data?.contactEmail ? ` · ${data.contactEmail}` : ''}
</p>
</div>
{data ? <InquiryConvertActions portSlug={portSlug} inquiry={data} /> : null}
</div>
}
tabs={tabs}
defaultTab="overview"
/>
);
}

View File

@@ -0,0 +1,33 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const inquiryFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search name or email…',
},
{
key: 'kind',
label: 'Type',
type: 'select',
options: [
{ label: 'Berth', value: 'berth_inquiry' },
{ label: 'Residence', value: 'residence_inquiry' },
{ label: 'Contact', value: 'contact_form' },
],
},
{
key: 'state',
label: 'Status',
type: 'select',
options: [
{ label: 'Inbox (open + assigned)', value: 'inbox' },
{ label: 'Open', value: 'open' },
{ label: 'Assigned', value: 'assigned' },
{ label: 'Converted', value: 'converted' },
{ label: 'Dismissed', value: 'dismissed' },
{ label: 'All', value: 'all' },
],
},
];

View File

@@ -0,0 +1,127 @@
'use client';
import { useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { ColumnPicker } from '@/components/shared/column-picker';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { inquiryFilterDefinitions } from '@/components/inquiries/inquiry-filters';
import {
getInquiryColumns,
INQUIRY_COLUMN_OPTIONS,
INQUIRY_DEFAULT_HIDDEN,
type InquiryRow,
type InquiryTriageState,
} from '@/components/inquiries/inquiry-columns';
import { InquiryCard } from '@/components/inquiries/inquiry-card';
export function InquiryList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Inquiries', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<InquiryRow>({
queryKey: ['inquiries'],
endpoint: '/api/v1/inquiries',
initialSort: { field: 'receivedAt', direction: 'desc' },
filterDefinitions: inquiryFilterDefinitions,
});
const triageMutation = useMutation({
mutationFn: (args: { id: string; state: InquiryTriageState }) =>
apiFetch(`/api/v1/inquiries/${args.id}/triage`, {
method: 'PATCH',
body: { state: args.state },
}),
onSuccess: (_d, vars) => {
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
toast.success(`Marked ${vars.state}.`);
},
onError: (err: unknown) => toastError(err, 'Update failed'),
});
const columns = getInquiryColumns({
portSlug,
onTriage: (row, state) => triageMutation.mutate({ id: row.id, state }),
});
const { hidden, setHidden } = useTablePreferences('inquiries', INQUIRY_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
return (
<div className="space-y-4">
<PageHeader
title="Inquiries"
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
variant="gradient"
/>
<div className="flex flex-wrap items-center gap-2">
<FilterBar
filters={inquiryFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<div className="ml-auto flex flex-wrap items-center gap-2">
<ColumnPicker columns={INQUIRY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
</div>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
cardRender={(row) => <InquiryCard inquiry={row.original} portSlug={portSlug} />}
emptyState={
<EmptyState
title="No inquiries found"
description="Submissions from the marketing site will appear here."
/>
}
/>
)}
</div>
);
}

View File

@@ -37,7 +37,12 @@ import { cn } from '@/lib/utils';
import { useNotifications } from '@/hooks/use-notifications';
import { NotificationItem } from '@/components/notifications/notification-item';
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts';
import {
useAlertCount,
useAlertList,
useAlertRealtime,
useDismissAll,
} from '@/components/alerts/use-alerts';
interface NotificationListResponse {
data: Array<{
@@ -66,6 +71,7 @@ export function Inbox() {
const systemCritical = alertCount?.bySeverity.critical ?? 0;
const systemAlerts = alertList?.data ?? [];
const systemTop = systemAlerts.slice(0, 8);
const dismissAll = useDismissAll();
// ── Personal (notifications) ──
const { unreadCount: personalUnread } = useNotifications();
@@ -230,13 +236,25 @@ export function Inbox() {
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active alerts
</h4>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
</Link>
<div className="flex items-center gap-3">
{systemAlerts.length > 0 ? (
<button
type="button"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Dismiss all
</button>
) : null}
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
</Link>
</div>
</div>
<Separator />
<ScrollArea className="max-h-[400px]">

View File

@@ -6,6 +6,7 @@ import {
Bookmark,
Building2,
FileSignature,
MailQuestion,
FileText,
Globe,
Home,
@@ -53,6 +54,7 @@ const MORE_GROUPS: MoreGroup[] = [
items: [
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Inquiries', icon: MailQuestion, segment: 'inquiries' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Residential', icon: Home, segment: 'residential/clients' },

View File

@@ -16,6 +16,7 @@ import {
FileText,
FileBarChart,
Inbox,
MailQuestion,
Camera,
Globe,
Settings,
@@ -115,6 +116,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
{ href: `${base}/inquiries`, label: 'Inquiries', icon: MailQuestion },
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
{
href: `${base}/tenancies`,

View File

@@ -69,6 +69,7 @@ export const PERMISSION_CATALOG = {
],
residential_clients: ['view', 'create', 'edit', 'delete'],
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
inquiries: ['view', 'manage'],
} as const satisfies {
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
};

View File

@@ -0,0 +1,25 @@
-- 0092_inquiries_permission.sql
-- ----------------------------------------------------------------------------
-- New `inquiries` permission resource (view/manage) backing the top-level
-- Inquiries workbench (previously the inbox lived under /admin and was gated on
-- admin.view_audit_log, which sales roles don't have).
--
-- Existing role rows are backfilled so the resource defaults to whatever the
-- role's `clients` access is: view ⟵ clients.view, manage ⟵ clients.create.
-- This lights up the right roles (anyone who can see/create clients) without a
-- manual per-role edit, and defaults to deny for read-only roles.
--
-- New-key only and idempotent via the `? 'inquiries'` guard, so re-running is a
-- no-op. Per-user / port-role override tables are intentionally left untouched:
-- the deep-merge resolver fills missing leaves from the base role (same
-- reasoning as 0041).
UPDATE roles
SET permissions = permissions || jsonb_build_object(
'inquiries', jsonb_build_object(
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
)
)
WHERE permissions IS NOT NULL
AND NOT (permissions ? 'inquiries');

View File

@@ -0,0 +1,31 @@
-- 0093_website_submissions_inquiry_cols.sql
-- ----------------------------------------------------------------------------
-- Inquiries workbench: tracking + display columns on website_submissions.
-- converted_client_id / converted_interest_id - set when an operator converts
-- an inquiry into CRM entities (FK to clients/interests).
-- contact_name / contact_email - extracted from the JSONB payload at capture
-- time so the list view can search/sort/display via real columns.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS + a
-- COALESCE backfill that only fills nulls. Safe to re-run.
ALTER TABLE website_submissions
ADD COLUMN IF NOT EXISTS converted_client_id text REFERENCES clients(id),
ADD COLUMN IF NOT EXISTS converted_interest_id text REFERENCES interests(id),
ADD COLUMN IF NOT EXISTS contact_name text,
ADD COLUMN IF NOT EXISTS contact_email text;
CREATE INDEX IF NOT EXISTS idx_ws_contact_email
ON website_submissions (port_id, contact_email);
-- Backfill display columns from existing payloads (only where still null).
UPDATE website_submissions
SET contact_email = COALESCE(contact_email, NULLIF(payload->>'email', '')),
contact_name = COALESCE(
contact_name,
NULLIF(TRIM(CONCAT_WS(' ', payload->>'first_name', payload->>'last_name')), ''),
NULLIF(payload->>'name', ''),
NULLIF(payload->>'fullName', ''),
NULLIF(payload->>'full_name', '')
)
WHERE contact_email IS NULL OR contact_name IS NULL;

View File

@@ -99,6 +99,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
export const ALERT_RULES = [
'reservation.no_agreement',
'interest.stale',
'interest.no_activity',
'document.signer_overdue',
'berth.under_offer_stalled',
'expense.duplicate',

View File

@@ -162,6 +162,10 @@ export type RolePermissions = {
delete: boolean;
change_stage: boolean;
};
inquiries: {
view: boolean;
manage: boolean;
};
};
/**

View File

@@ -51,6 +51,18 @@ export const websiteSubmissions = pgTable(
* same form submission. Useful for reconciling: pick any submission
* here, look up the matching NocoDB row, confirm both halves agree. */
legacyNocodbId: text('legacy_nocodb_id'),
/** Contact name + email extracted from `payload` at capture time so the
* inquiry list can search/sort/display via real columns (payload stays
* JSONB and isn't searched directly). Populated by the capture endpoint
* and backfilled in migration 0093. */
contactName: text('contact_name'),
contactEmail: text('contact_email'),
/** Set when an operator converts this inquiry into CRM entities. FK enforced
* at the DB level (migration 0093); typed as plain text here to avoid a
* circular schema import — `clients`/`interests` already reference
* `website_submissions`. */
convertedClientId: text('converted_client_id'),
convertedInterestId: text('converted_interest_id'),
/** Capture-time metadata for debugging. */
sourceIp: text('source_ip'),
userAgent: text('user_agent'),

View File

@@ -88,6 +88,10 @@ export const ALL_PERMISSIONS: RolePermissions = {
delete: true,
change_stage: true,
},
inquiries: {
view: true,
manage: true,
},
};
export const DIRECTOR_PERMISSIONS: RolePermissions = {
@@ -167,6 +171,10 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
delete: true,
change_stage: true,
},
inquiries: {
view: true,
manage: true,
},
};
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
@@ -246,6 +254,10 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: false,
},
inquiries: {
view: true,
manage: true,
},
};
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
@@ -325,6 +337,10 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: false,
},
inquiries: {
view: true,
manage: true,
},
};
export const VIEWER_PERMISSIONS: RolePermissions = {
@@ -410,6 +426,10 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: false,
},
inquiries: {
view: true,
manage: false,
},
};
// Residential Partner - for an outside party who handles residential
@@ -498,4 +518,8 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
delete: false,
change_stage: true,
},
inquiries: {
view: false,
manage: false,
},
};

View File

@@ -32,6 +32,46 @@ function daysAgo(n: number): Date {
return new Date(Date.now() - n * DAY_MS);
}
// ─── shared interest-activity fragments ───────────────────────────────────────
// Correlated subqueries keyed on `interests.id`, reused by the interest rules.
/**
* True when the interest was created by the legacy→CRM bulk import. The
* migration ledger is the only reliable marker (no column on `interests`).
*/
const isImportedSql = sql`EXISTS (
SELECT 1 FROM migration_source_links msl
WHERE msl.source_system = 'nocodb_interests'
AND msl.target_entity_type = 'interest'
AND msl.target_entity_id = ${interests.id}
)`;
/**
* True when a real user has worked the interest in-system: a logged contact, a
* note, or an UPDATE audit by a real user. The initial create-audit is excluded
* (action='update' only) so a bare, never-touched creation does not count.
*/
const hasFollowupSql = sql`(
EXISTS (SELECT 1 FROM interest_contact_log icl WHERE icl.interest_id = ${interests.id})
OR EXISTS (SELECT 1 FROM interest_notes inn WHERE inn.interest_id = ${interests.id})
OR EXISTS (
SELECT 1 FROM audit_logs al
WHERE al.entity_type = 'interest' AND al.entity_id = ${interests.id}
AND al.user_id IS NOT NULL AND al.action = 'update'
)
)`;
/**
* Most recent genuine in-system touch, used as the staleness clock. Coalesced to
* '-infinity' so GREATEST never returns NULL.
*/
const lastTouchAtSql = sql`GREATEST(
COALESCE(${interests.dateLastContact}, '-infinity'::timestamptz),
COALESCE((SELECT max(icl.occurred_at) FROM interest_contact_log icl WHERE icl.interest_id = ${interests.id}), '-infinity'::timestamptz),
COALESCE((SELECT max(inn.created_at) FROM interest_notes inn WHERE inn.interest_id = ${interests.id}), '-infinity'::timestamptz),
COALESCE((SELECT max(al.created_at) FROM audit_logs al WHERE al.entity_type='interest' AND al.entity_id=${interests.id} AND al.user_id IS NOT NULL AND al.action='update'), '-infinity'::timestamptz)
)`;
// ─── reservation.no_agreement ─────────────────────────────────────────────────
// Active reservations > 3 days old that have no reservation_agreement document
// in any non-cancelled state.
@@ -70,22 +110,18 @@ async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]>
}));
}
// Mid-funnel stages where silence is a problem. EOI / reservation / deposit /
// contract stages have their own dedicated alerts (eoi.unsigned_long,
// reservation.no_agreement, etc.), so these rules sit before signing kicks in.
const ACTIVE_EARLY_STAGES = ['enquiry', 'qualified', 'nurturing'];
// ─── interest.stale ───────────────────────────────────────────────────────────
// Pipeline stuck in mid-funnel stages with no contact for 14+ days.
// A lead a user actually WORKED in-system (logged a contact / note / made an
// update) that has since gone quiet for 14+ days. Interests that were merely
// imported and never touched are handled by interest.no_activity, not here — so
// the bulk-import backlog never lands in this rule.
async function interestStale(portId: string): Promise<AlertCandidate[]> {
// Mid-funnel stages where silence is a problem. EOI / reservation /
// deposit / contract stages have their own dedicated alerts
// (eoi.unsigned_long, reservation.no_agreement, deposit_overdue, etc.),
// so this alert sits before signing kicks in.
//
// 2026-05-14 pipeline-refactor sweep: the prior values
// ('details_sent', 'in_communication', 'eoi_sent') were collapsed by
// migration 0062 into the 7-stage canon (enquiry / qualified /
// nurturing / eoi / ...). Until this fix landed, this alert never
// fired because no row in the new schema carried the dead stage
// strings.
const STALE_STAGES = ['enquiry', 'qualified', 'nurturing'];
const rows = await db
.select({
id: interests.id,
@@ -97,25 +133,10 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
.where(
and(
eq(interests.portId, portId),
inArray(interests.pipelineStage, STALE_STAGES),
inArray(interests.pipelineStage, ACTIVE_EARLY_STAGES),
isNull(interests.archivedAt),
// An interest can't be "stale for 14+ days" if it has only existed in
// THIS system for less than 14 days. Without this floor, a bulk import
// (which backdates dateLastContact to the legacy value) instantly flags
// every migrated interest as stale and floods the alert rail.
//
// We floor on updatedAt, NOT createdAt: the legacy→CRM migration
// backfilled created_at to each interest's real origination date (so
// analytics date-ranges work), which would make every migrated row look
// 14+ days old and re-open the flood. updated_at is left at the
// migration timestamp, so it's the reliable "entered/last-touched this
// system" clock — migrated rows stay suppressed for 14 days, then the
// contact-based OR below governs.
lt(interests.updatedAt, daysAgo(14)),
or(
lt(interests.dateLastContact, daysAgo(14)),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),
),
sql`${hasFollowupSql}`,
sql`${lastTouchAtSql} < now() - interval '14 days'`,
),
);
@@ -123,7 +144,7 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
ruleId: 'interest.stale',
severity: 'info',
title: `Stale interest: ${r.clientName}`,
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' with no contact for 14+ days.`,
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' — worked but no activity for 14+ days.`,
link: `/[port]/interests/${r.id}`,
entityType: 'interest',
entityId: r.id,
@@ -131,6 +152,42 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
}));
}
// ─── interest.no_activity ─────────────────────────────────────────────────────
// A brand-new inbound interest nobody has touched in-system, 14+ days after it
// arrived. Excludes bulk-imported rows (those live in migration_source_links)
// so the historical backlog never nags.
async function interestNoActivity(portId: string): Promise<AlertCandidate[]> {
const rows = await db
.select({
id: interests.id,
stage: interests.pipelineStage,
clientName: sql<string>`coalesce((SELECT full_name FROM clients WHERE id = ${interests.clientId}), 'unknown')`,
})
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.pipelineStage, ACTIVE_EARLY_STAGES),
isNull(interests.archivedAt),
lt(interests.createdAt, daysAgo(14)),
sql`NOT ${hasFollowupSql}`,
sql`NOT ${isImportedSql}`,
),
);
return rows.map((r) => ({
ruleId: 'interest.no_activity',
severity: 'info',
title: `New inquiry untouched: ${r.clientName}`,
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' — no activity since it arrived 14+ days ago.`,
link: `/[port]/interests/${r.id}`,
entityType: 'interest',
entityId: r.id,
metadata: { stage: r.stage },
}));
}
// ─── document.signer_overdue ──────────────────────────────────────────────────
// Pending signer for >14d, last reminder >7d ago (or never).
@@ -282,6 +339,10 @@ async function interestHighValueSilent(portId: string): Promise<AlertCandidate[]
eq(interests.portId, portId),
eq(interests.leadCategory, 'hot_lead'),
isNull(interests.archivedAt),
// Don't flood from imported-but-never-touched hot leads (their
// dateLastContact is back-dated to a legacy date). Once a user works one
// in-system, it becomes eligible again.
sql`( NOT ${isImportedSql} OR ${hasFollowupSql} )`,
or(
lt(interests.dateLastContact, cutoff),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, cutoff)),
@@ -335,6 +396,7 @@ async function eoiUnsignedLong(portId: string): Promise<AlertCandidate[]> {
export const RULE_REGISTRY: Record<AlertRuleId, RuleEvaluator> = {
'reservation.no_agreement': reservationNoAgreement,
'interest.stale': interestStale,
'interest.no_activity': interestNoActivity,
'document.signer_overdue': documentSignerOverdue,
'berth.under_offer_stalled': berthUnderOfferStalled,
'expense.duplicate': expenseDuplicate,

View File

@@ -120,6 +120,42 @@ export async function dismissAlert(alertId: string, portId: string, userId: stri
}
}
/**
* Bulk-dismiss every open (non-dismissed, non-resolved) alert for a port,
* optionally narrowed to a single rule and/or severity. Returns the count
* dismissed. Port-scoped so it can never touch another tenant's alerts.
*/
export async function dismissAllForPort(
portId: string,
userId: string,
filter: { ruleId?: AlertRuleId; severity?: AlertSeverity } = {},
): Promise<number> {
const conds = [eq(alerts.portId, portId), isNull(alerts.dismissedAt), isNull(alerts.resolvedAt)];
if (filter.ruleId) conds.push(eq(alerts.ruleId, filter.ruleId));
if (filter.severity) conds.push(eq(alerts.severity, filter.severity));
const rows = await db
.update(alerts)
.set({ dismissedAt: sql`now()`, dismissedBy: userId })
.where(and(...conds))
.returning({ id: alerts.id });
for (const r of rows) {
emitToRoom(`port:${portId}`, 'alert:dismissed', { alertId: r.id, portId });
}
if (rows.length > 0) {
void createAuditLog({
portId,
userId,
action: 'update',
entityType: 'alert',
entityId: portId, // port-wide bulk action — no single alert subject
metadata: { kind: 'dismiss_all', count: rows.length, filter },
});
}
return rows.length;
}
export async function acknowledgeAlert(
alertId: string,
portId: string,

View File

@@ -0,0 +1,242 @@
/**
* Inquiries workbench service — read/triage/convert over `website_submissions`.
*
* The capture endpoint (`/api/public/website-inquiries`) writes raw submissions;
* this service is the operator-facing layer: list/filter, triage state changes,
* and converting an inquiry into proper CRM entities (client and/or interest)
* with the submission row linked back to what it produced.
*/
import { and, eq, inArray, isNull, sql, type SQL } from 'drizzle-orm';
import { db } from '@/lib/db';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { createClient } from './clients.service';
import { createInterest } from './interests.service';
import { extractInquiryFields } from './website-intake-fields';
import { createClientSchema } from '@/lib/validators/clients';
import { createInterestSchema } from '@/lib/validators/interests';
import type { ListInquiriesInput } from '@/lib/validators/inquiries';
type TriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
const SORTABLE = {
receivedAt: websiteSubmissions.receivedAt,
kind: websiteSubmissions.kind,
triageState: websiteSubmissions.triageState,
contactName: websiteSubmissions.contactName,
} as const;
export async function listInquiries(portId: string, query: ListInquiriesInput) {
const filters: SQL[] = [];
if (query.kind) filters.push(eq(websiteSubmissions.kind, query.kind));
if (query.state === 'inbox') {
filters.push(inArray(websiteSubmissions.triageState, ['open', 'assigned']));
} else if (query.state !== 'all') {
filters.push(eq(websiteSubmissions.triageState, query.state));
}
const sortColumn =
query.sort && query.sort in SORTABLE
? SORTABLE[query.sort as keyof typeof SORTABLE]
: undefined;
return buildListQuery<typeof websiteSubmissions.$inferSelect>({
table: websiteSubmissions,
portIdColumn: websiteSubmissions.portId,
portId,
idColumn: websiteSubmissions.id,
// website_submissions has no updatedAt; receivedAt is the natural clock and
// the deterministic tail-sort.
updatedAtColumn: websiteSubmissions.receivedAt,
searchColumns: [websiteSubmissions.contactName, websiteSubmissions.contactEmail],
searchTerm: query.search,
filters,
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
page: query.page,
pageSize: query.limit,
});
}
export async function getInquiryById(id: string, portId: string) {
const row = await loadInquiry(id, portId);
const convertedClient = row.convertedClientId
? ((
await db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(eq(clients.id, row.convertedClientId))
.limit(1)
)[0] ?? null)
: null;
const convertedInterest = row.convertedInterestId
? ((
await db
.select({ id: interests.id, pipelineStage: interests.pipelineStage })
.from(interests)
.where(eq(interests.id, row.convertedInterestId))
.limit(1)
)[0] ?? null)
: null;
return { ...row, convertedClient, convertedInterest };
}
export async function triageInquiry(
id: string,
portId: string,
state: TriageState,
meta: AuditMeta,
) {
const [updated] = await db
.update(websiteSubmissions)
.set({ triageState: state, triagedAt: new Date(), triagedBy: meta.userId })
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('inquiry');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'website_submission',
entityId: id,
fieldChanged: 'triageState',
newValue: { triageState: state },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return updated;
}
export async function convertInquiryToClient(id: string, portId: string, meta: AuditMeta) {
const row = await loadInquiry(id, portId);
// Idempotent: if already linked to a client, return it rather than duplicate.
if (row.convertedClientId) return { clientId: row.convertedClientId, interestId: null };
const clientId = await findOrCreateClientFromInquiry(row, meta);
await markConverted(id, portId, { clientId }, meta);
return { clientId, interestId: null };
}
export async function convertInquiryToInterest(id: string, portId: string, meta: AuditMeta) {
const row = await loadInquiry(id, portId);
if (row.convertedInterestId) {
throw new ConflictError('Inquiry has already been converted to an interest.');
}
const clientId = row.convertedClientId ?? (await findOrCreateClientFromInquiry(row, meta));
const interestData = createInterestSchema.parse({
clientId,
pipelineStage: 'enquiry',
source: 'website',
});
const interest = await createInterest(portId, interestData, meta);
await markConverted(id, portId, { clientId, interestId: interest.id }, meta);
return { clientId, interestId: interest.id };
}
// ─── internals ────────────────────────────────────────────────────────────────
async function loadInquiry(id: string, portId: string) {
const [row] = await db
.select()
.from(websiteSubmissions)
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)))
.limit(1);
if (!row) throw new NotFoundError('inquiry');
return row;
}
/**
* Find a single in-port client whose email contact matches the inquiry's email,
* else create a new client from the payload. Returns the client id.
*/
async function findOrCreateClientFromInquiry(
row: typeof websiteSubmissions.$inferSelect,
meta: AuditMeta,
): Promise<string> {
const fields = extractInquiryFields((row.payload ?? {}) as Record<string, unknown>);
const email = (row.contactEmail ?? fields.email ?? '').trim();
if (email) {
const matches = await db
.selectDistinct({ id: clients.id })
.from(clients)
.innerJoin(clientContacts, eq(clientContacts.clientId, clients.id))
.where(
and(
eq(clients.portId, meta.portId),
isNull(clients.archivedAt),
eq(clientContacts.channel, 'email'),
sql`lower(${clientContacts.value}) = ${email.toLowerCase()}`,
),
)
.limit(2);
// Only auto-link on an unambiguous single match.
if (matches.length === 1) return matches[0]!.id;
}
const contacts: Array<{
channel: 'email' | 'phone' | 'other';
value: string;
isPrimary?: boolean;
}> = [];
if (email) contacts.push({ channel: 'email', value: email, isPrimary: true });
const phone = (fields.phone ?? '').trim();
if (phone) contacts.push({ channel: 'phone', value: phone });
if (contacts.length === 0) {
// Schema requires ≥1 contact; fall back to a name-bearing "other" contact.
contacts.push({ channel: 'other', value: row.contactName ?? 'Website inquiry' });
}
const fullName = (row.contactName ?? fields.fullName ?? email ?? '').trim() || 'Website inquiry';
const clientData = createClientSchema.parse({
fullName,
contacts,
source: 'website',
sourceInquiryId: row.id,
});
const client = await createClient(meta.portId, clientData, meta);
return client.id;
}
async function markConverted(
id: string,
portId: string,
refs: { clientId: string; interestId?: string },
meta: AuditMeta,
) {
await db
.update(websiteSubmissions)
.set({
convertedClientId: refs.clientId,
...(refs.interestId ? { convertedInterestId: refs.interestId } : {}),
triageState: 'converted',
triagedAt: new Date(),
triagedBy: meta.userId,
})
.where(and(eq(websiteSubmissions.id, id), eq(websiteSubmissions.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'website_submission',
entityId: id,
fieldChanged: 'triageState',
newValue: { triageState: 'converted', ...refs },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
/**
* List query for the inquiries workbench (over `website_submissions`).
* `state` defaults to 'inbox' (open + assigned) so resolved/dismissed roll off
* the active queue; pass 'all' for the full history.
*/
export const listInquiriesSchema = baseListQuerySchema.extend({
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']).optional(),
state: z.enum(['inbox', 'open', 'assigned', 'converted', 'dismissed', 'all']).default('inbox'),
});
export const triageInquirySchema = z.object({
state: z.enum(['open', 'assigned', 'converted', 'dismissed']),
});
export const convertInquirySchema = z.object({
target: z.enum(['client', 'interest']),
});
export type ListInquiriesInput = z.infer<typeof listInquiriesSchema>;
export type TriageInquiryInput = z.infer<typeof triageInquirySchema>;
export type ConvertInquiryInput = z.infer<typeof convertInquirySchema>;

View File

@@ -30,6 +30,7 @@ export async function teardown() {
)
-- Cascade-delete dependent rows. Order respects FK chains.
, del_audit AS (DELETE FROM audit_logs WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_ws AS (DELETE FROM website_submissions WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_bml AS (DELETE FROM berth_maintenance_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_resv AS (DELETE FROM berth_tenancies WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_caddr AS (DELETE FROM client_addresses WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
@@ -47,6 +48,7 @@ export async function teardown() {
, del_files AS (DELETE FROM files WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_ft AS (DELETE FROM form_templates WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_gr AS (DELETE FROM generated_reports WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_icl AS (DELETE FROM interest_contact_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_int AS (DELETE FROM interests WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_ib AS (DELETE FROM interest_berths WHERE berth_id IN (SELECT id FROM berths WHERE port_id IN (SELECT id FROM doomed)) RETURNING 1)
, del_inv AS (DELETE FROM invoices WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
@@ -69,5 +71,8 @@ export async function teardown() {
, del_ports AS (DELETE FROM ports WHERE id IN (SELECT id FROM doomed) RETURNING 1)
SELECT 1
`);
// migration_source_links has no port_id FK; purge test-only ledger rows by
// the marker applied_id our alert tests use.
await db.execute(sql`DELETE FROM migration_source_links WHERE applied_id = 'test-apply'`);
await closeDb();
}

View File

@@ -384,6 +384,7 @@ export function makeFullPermissions(): RolePermissions {
delete: true,
change_stage: true,
},
inquiries: { view: true, manage: true },
};
}
@@ -472,6 +473,7 @@ export function makeViewerPermissions(): RolePermissions {
delete: false,
change_stage: false,
},
inquiries: { view: true, manage: false },
};
}
@@ -560,6 +562,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
delete: false,
change_stage: false,
},
inquiries: { view: true, manage: true },
};
}
@@ -648,6 +651,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
delete: true,
change_stage: true,
},
inquiries: { view: true, manage: true },
};
}

View File

@@ -0,0 +1,70 @@
/**
* Bulk-dismiss service: dismissAllForPort must respect the optional rule/
* severity filter and never touch another port's alerts.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { and, eq, isNull } from 'drizzle-orm';
vi.mock('@/lib/socket/server', () => ({
emitToRoom: vi.fn(),
}));
import { db } from '@/lib/db';
import { alerts } from '@/lib/db/schema/insights';
import { user } from '@/lib/db/schema/users';
import { dismissAllForPort } from '@/lib/services/alerts.service';
import { makePort } from '../helpers/factories';
let USER_ID = '';
beforeAll(async () => {
const [u] = await db.select({ id: user.id }).from(user).limit(1);
if (!u) throw new Error('No user available; run pnpm db:seed first');
USER_ID = u.id;
});
async function seedAlert(portId: string, ruleId: string, severity = 'info') {
const [row] = await db
.insert(alerts)
.values({
portId,
ruleId,
severity,
title: `t-${ruleId}`,
link: '/x',
fingerprint: `fp-${Math.random().toString(36).slice(2)}`,
metadata: {},
})
.returning({ id: alerts.id });
return row!.id;
}
async function openCount(portId: string) {
const rows = await db
.select()
.from(alerts)
.where(and(eq(alerts.portId, portId), isNull(alerts.dismissedAt), isNull(alerts.resolvedAt)));
return rows.length;
}
describe('dismissAllForPort', () => {
it('dismisses only the filtered rule, scoped to the port, then all', async () => {
const portA = await makePort();
const portB = await makePort();
await seedAlert(portA.id, 'interest.stale');
await seedAlert(portA.id, 'interest.stale');
await seedAlert(portA.id, 'document.signer_overdue', 'warning');
await seedAlert(portB.id, 'interest.stale');
const filtered = await dismissAllForPort(portA.id, USER_ID, { ruleId: 'interest.stale' });
expect(filtered).toBe(2);
expect(await openCount(portA.id)).toBe(1); // signer_overdue remains
expect(await openCount(portB.id)).toBe(1); // other port untouched
const rest = await dismissAllForPort(portA.id, USER_ID);
expect(rest).toBe(1);
expect(await openCount(portA.id)).toBe(0);
expect(await openCount(portB.id)).toBe(1);
});
});

View File

@@ -18,6 +18,8 @@ import { alerts } from '@/lib/db/schema/insights';
import { interests } from '@/lib/db/schema/interests';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { documents } from '@/lib/db/schema/documents';
import { interestContactLog } from '@/lib/db/schema/operations';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
import { makePort, makeClient, makeBerth, makeYacht } from '../helpers/factories';
@@ -32,6 +34,30 @@ async function listOpenAlerts(portId: string, ruleId: string) {
.where(and(eq(alerts.portId, portId), eq(alerts.ruleId, ruleId), isNull(alerts.resolvedAt)));
}
/** Mark an interest as bulk-imported via the migration ledger. */
async function markImported(interestId: string) {
await db.insert(migrationSourceLinks).values({
sourceSystem: 'nocodb_interests',
sourceId: `legacy-${interestId}`,
targetEntityType: 'interest',
targetEntityId: interestId,
appliedId: 'test-apply',
});
}
/** A genuine in-system follow-up: a logged contact at `occurredAt`. */
async function logContact(portId: string, interestId: string, occurredAt: Date) {
await db.insert(interestContactLog).values({
portId,
interestId,
occurredAt,
channel: 'phone',
direction: 'outbound',
summary: 'Test follow-up',
createdBy: 'seed',
});
}
describe('alert engine', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -149,7 +175,7 @@ describe('alert engine', () => {
expect(allRows[0]!.resolvedAt).not.toBeNull();
});
it('interest.stale fires for old leads in mid-funnel stages', async () => {
it('interest.stale fires for worked leads gone quiet >14d', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const stale = new Date(Date.now() - 30 * 86_400_000);
@@ -164,6 +190,9 @@ describe('alert engine', () => {
updatedAt: stale,
})
.returning();
// A real in-system follow-up 30 days ago → this is a worked-then-quiet lead,
// not an untouched import.
await logContact(port.id, interest!.id, stale);
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
@@ -172,6 +201,60 @@ describe('alert engine', () => {
expect(open).toHaveLength(1);
expect(open[0]!.entityId).toBe(interest!.id);
expect(open[0]!.severity).toBe('info');
// A worked lead must not also fire the new-untouched rule.
expect(await listOpenAlerts(port.id, 'interest.no_activity')).toHaveLength(0);
});
it('interest.stale does NOT fire for imported, never-touched interests', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const migrationTime = new Date(Date.now() - 20 * 86_400_000);
const legacyDate = new Date(Date.now() - 3 * 365 * 86_400_000); // ~3yr back-dated
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
pipelineStage: 'qualified',
dateLastContact: legacyDate, // back-dated by the migration
createdAt: migrationTime,
updatedAt: migrationTime,
})
.returning();
await markImported(interest!.id);
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
// Imported + never touched in-system → neither interest rule should fire.
expect(await listOpenAlerts(port.id, 'interest.stale')).toHaveLength(0);
expect(await listOpenAlerts(port.id, 'interest.no_activity')).toHaveLength(0);
});
it('interest.no_activity fires for new, non-imported, untouched interests >14d old', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const created = new Date(Date.now() - 20 * 86_400_000);
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
pipelineStage: 'enquiry',
dateLastContact: null,
createdAt: created,
updatedAt: created,
})
.returning();
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
const open = await listOpenAlerts(port.id, 'interest.no_activity');
expect(open).toHaveLength(1);
expect(open[0]!.entityId).toBe(interest!.id);
expect(open[0]!.severity).toBe('info');
expect(await listOpenAlerts(port.id, 'interest.stale')).toHaveLength(0);
});
it('interest.high_value_silent fires for hot leads silent >7d', async () => {
@@ -195,6 +278,31 @@ describe('alert engine', () => {
expect(open[0]!.severity).toBe('critical');
});
it('interest.high_value_silent skips imported, never-touched hot leads', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const migrationTime = new Date(Date.now() - 20 * 86_400_000);
const legacyDate = new Date(Date.now() - 3 * 365 * 86_400_000);
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
pipelineStage: 'qualified',
leadCategory: 'hot_lead',
dateLastContact: legacyDate,
createdAt: migrationTime,
updatedAt: migrationTime,
})
.returning();
await markImported(interest!.id);
await clearAlerts(port.id);
await runAlertEngineForPorts([port.id]);
expect(await listOpenAlerts(port.id, 'interest.high_value_silent')).toHaveLength(0);
});
it('engine reports rule errors without crashing the sweep', async () => {
const port = await makePort();
const summary = await runAlertEngineForPorts([port.id]);

View File

@@ -0,0 +1,205 @@
/**
* Inquiries workbench service: list/filter/triage/get + convert-to-client and
* convert-to-interest (find-or-create client, tracking columns, port isolation).
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { eq } from 'drizzle-orm';
vi.mock('@/lib/socket/server', () => ({
emitToRoom: vi.fn(),
}));
import { db } from '@/lib/db';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { user } from '@/lib/db/schema/users';
import {
listInquiries,
getInquiryById,
triageInquiry,
convertInquiryToClient,
convertInquiryToInterest,
} from '@/lib/services/inquiries.service';
import { makePort } from '../helpers/factories';
import type { AuditMeta } from '@/lib/audit';
let META: AuditMeta;
beforeAll(async () => {
const [u] = await db.select({ id: user.id }).from(user).limit(1);
if (!u) throw new Error('No user available; run pnpm db:seed first');
META = { userId: u.id, portId: '', ipAddress: '127.0.0.1', userAgent: 'test' };
});
function metaFor(portId: string): AuditMeta {
return { ...META, portId };
}
async function seedInquiry(
portId: string,
opts: {
kind?: 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
triageState?: string;
contactName?: string | null;
contactEmail?: string | null;
payload?: Record<string, unknown>;
} = {},
) {
const [row] = await db
.insert(websiteSubmissions)
.values({
portId,
submissionId: crypto.randomUUID(),
kind: opts.kind ?? 'contact_form',
payload: opts.payload ?? {},
contactName: opts.contactName ?? 'Jane Doe',
contactEmail: opts.contactEmail ?? 'jane@example.com',
triageState: opts.triageState ?? 'open',
})
.returning();
return row!;
}
describe('inquiries.service — list / get / triage', () => {
it('filters by kind and state, searches name/email, scoped to port', async () => {
const port = await makePort();
const other = await makePort();
await seedInquiry(port.id, {
kind: 'contact_form',
contactName: 'Alice Smith',
contactEmail: 'alice@x.com',
});
await seedInquiry(port.id, {
kind: 'berth_inquiry',
contactName: 'Bob Jones',
contactEmail: 'bob@x.com',
});
await seedInquiry(port.id, {
kind: 'contact_form',
triageState: 'dismissed',
contactName: 'Carol',
});
await seedInquiry(other.id, {
kind: 'contact_form',
contactName: 'Alice Smith',
contactEmail: 'alice@x.com',
});
const base = { page: 1, limit: 25, order: 'desc' as const, includeArchived: false };
// inbox (open+assigned) excludes the dismissed one
const inbox = await listInquiries(port.id, { ...base, state: 'inbox' });
expect(inbox.total).toBe(2);
// kind filter
const contacts = await listInquiries(port.id, { ...base, state: 'all', kind: 'contact_form' });
expect(contacts.total).toBe(2);
// search by name
const alice = await listInquiries(port.id, { ...base, state: 'all', search: 'alice' });
expect(alice.total).toBe(1);
expect(alice.data[0]!.contactName).toBe('Alice Smith');
// port isolation
expect(alice.data.every((r) => r.portId === port.id)).toBe(true);
});
it('triageInquiry updates state + triagedBy; getInquiryById returns the row', async () => {
const port = await makePort();
const row = await seedInquiry(port.id);
const updated = await triageInquiry(row.id, port.id, 'assigned', metaFor(port.id));
expect(updated.triageState).toBe('assigned');
expect(updated.triagedBy).toBe(META.userId);
const fetched = await getInquiryById(row.id, port.id);
expect(fetched.id).toBe(row.id);
expect(fetched.convertedClient).toBeNull();
});
});
describe('inquiries.service — convert', () => {
it('convert to client creates a new client when no email match', async () => {
const port = await makePort();
const row = await seedInquiry(port.id, {
contactName: 'New Lead',
contactEmail: 'newlead@example.com',
payload: {
first_name: 'New',
last_name: 'Lead',
email: 'newlead@example.com',
phone: '+15551234567',
},
});
const res = await convertInquiryToClient(row.id, port.id, metaFor(port.id));
expect(res.clientId).toBeTruthy();
expect(res.interestId).toBeNull();
const [c] = await db.select().from(clients).where(eq(clients.id, res.clientId)).limit(1);
expect(c!.fullName).toBe('New Lead');
expect(c!.source).toBe('website');
const sub = await getInquiryById(row.id, port.id);
expect(sub.triageState).toBe('converted');
expect(sub.convertedClientId).toBe(res.clientId);
});
it('convert links an existing client on a unique email match (no duplicate)', async () => {
const port = await makePort();
// Pre-existing client with the same email.
const [existing] = await db
.insert(clients)
.values({ portId: port.id, fullName: 'Existing Client', source: 'manual' })
.returning();
await db.insert(clientContacts).values({
clientId: existing!.id,
channel: 'email',
value: 'dup@example.com',
isPrimary: true,
});
const row = await seedInquiry(port.id, {
contactEmail: 'dup@example.com',
payload: { email: 'dup@example.com' },
});
const res = await convertInquiryToClient(row.id, port.id, metaFor(port.id));
expect(res.clientId).toBe(existing!.id);
const all = await db.select().from(clients).where(eq(clients.portId, port.id));
expect(all).toHaveLength(1); // no duplicate created
});
it('convert to interest find-or-creates the client and creates an interest', async () => {
const port = await makePort();
const row = await seedInquiry(port.id, {
contactName: 'Deal Maker',
contactEmail: 'deal@example.com',
payload: { first_name: 'Deal', last_name: 'Maker', email: 'deal@example.com' },
});
const res = await convertInquiryToInterest(row.id, port.id, metaFor(port.id));
expect(res.clientId).toBeTruthy();
expect(res.interestId).toBeTruthy();
const [i] = await db.select().from(interests).where(eq(interests.id, res.interestId!)).limit(1);
expect(i!.clientId).toBe(res.clientId);
expect(i!.pipelineStage).toBe('enquiry');
const sub = await getInquiryById(row.id, port.id);
expect(sub.triageState).toBe('converted');
expect(sub.convertedInterestId).toBe(res.interestId);
expect(sub.convertedClientId).toBe(res.clientId);
});
it('convert to interest twice is rejected', async () => {
const port = await makePort();
const row = await seedInquiry(port.id, {
contactEmail: 'once@example.com',
payload: { email: 'once@example.com' },
});
await convertInquiryToInterest(row.id, port.id, metaFor(port.id));
await expect(convertInquiryToInterest(row.id, port.id, metaFor(port.id))).rejects.toThrow();
});
});