Compare commits
8 Commits
7591231c47
...
4d018be800
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d018be800 | |||
| 95d7776bb6 | |||
| 0cc05f302f | |||
| 54554a0928 | |||
| 9879b82e5f | |||
| 08adb4aeea | |||
| 6c4490f653 | |||
| 13efe177a5 |
176
scripts/import-website-inquiries-from-nocodb.ts
Normal file
176
scripts/import-website-inquiries-from-nocodb.ts
Normal 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;
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal file
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InquiryList } from '@/components/inquiries/inquiry-list';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryList />;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal file
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal file
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
18
src/app/api/v1/inquiries/[id]/route.ts
Normal file
18
src/app/api/v1/inquiries/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal file
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
33
src/app/api/v1/inquiries/route.ts
Normal file
33
src/app/api/v1/inquiries/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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']],
|
||||
|
||||
35
src/components/inquiries/inquiry-card.tsx
Normal file
35
src/components/inquiries/inquiry-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
src/components/inquiries/inquiry-columns.tsx
Normal file
217
src/components/inquiries/inquiry-columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal file
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
src/components/inquiries/inquiry-detail.tsx
Normal file
184
src/components/inquiries/inquiry-detail.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
src/components/inquiries/inquiry-filters.tsx
Normal file
33
src/components/inquiries/inquiry-filters.tsx
Normal 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
127
src/components/inquiries/inquiry-list.tsx
Normal file
127
src/components/inquiries/inquiry-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]">
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
25
src/lib/db/migrations/0092_inquiries_permission.sql
Normal file
25
src/lib/db/migrations/0092_inquiries_permission.sql
Normal 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');
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -162,6 +162,10 @@ export type RolePermissions = {
|
||||
delete: boolean;
|
||||
change_stage: boolean;
|
||||
};
|
||||
inquiries: {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
242
src/lib/services/inquiries.service.ts
Normal file
242
src/lib/services/inquiries.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
25
src/lib/validators/inquiries.ts
Normal file
25
src/lib/validators/inquiries.ts
Normal 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>;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
70
tests/integration/alerts-dismiss-all.test.ts
Normal file
70
tests/integration/alerts-dismiss-all.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
205
tests/integration/inquiries.service.test.ts
Normal file
205
tests/integration/inquiries.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user