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,
|
autoPromoteWebsiteBerthInquiry,
|
||||||
isWebsiteBerthAutopromoteEnabled,
|
isWebsiteBerthAutopromoteEnabled,
|
||||||
} from '@/lib/services/website-intake-promote.service';
|
} from '@/lib/services/website-intake-promote.service';
|
||||||
|
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/public/website-inquiries
|
* 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
|
// 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
|
// return its id, mirroring the first-delivery shape so the website never
|
||||||
// sees a difference between fresh and dup.
|
// 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
|
const insertResult = await db
|
||||||
.insert(websiteSubmissions)
|
.insert(websiteSubmissions)
|
||||||
.values({
|
.values({
|
||||||
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
|
|||||||
kind: parsed.kind,
|
kind: parsed.kind,
|
||||||
payload: parsed.payload,
|
payload: parsed.payload,
|
||||||
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
||||||
|
contactName: fields.fullName || null,
|
||||||
|
contactEmail: fields.email || null,
|
||||||
sourceIp: ip,
|
sourceIp: ip,
|
||||||
userAgent: req.headers.get('user-agent') ?? null,
|
userAgent: req.headers.get('user-agent') ?? null,
|
||||||
utmSource: parsed.utm_source ?? 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,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: false,
|
||||||
|
manage: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
@@ -126,6 +130,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
admin: 'Administration',
|
admin: 'Administration',
|
||||||
residential_clients: 'Residential Clients',
|
residential_clients: 'Residential Clients',
|
||||||
residential_interests: 'Residential Interests',
|
residential_interests: 'Residential Interests',
|
||||||
|
inquiries: 'Inquiries',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAction(action: string): string {
|
function formatAction(action: string): string {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!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 ? (
|
{!acknowledged ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { useState } from 'react';
|
|||||||
import { ShieldAlert } from 'lucide-react';
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
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';
|
import type { AlertStatus } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
|||||||
|
|
||||||
const total = count?.total ?? 0;
|
const total = count?.total ?? 0;
|
||||||
const alerts = data?.data ?? [];
|
const alerts = data?.data ?? [];
|
||||||
|
const dismissAll = useDismissAll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
||||||
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value={tab} className="mt-4 space-y-2">
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-20 w-full" />
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ export function useAlertActions() {
|
|||||||
return { acknowledge, dismiss };
|
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() {
|
export function useAlertRealtime() {
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'alert:created': [['alerts']],
|
'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 { useNotifications } from '@/hooks/use-notifications';
|
||||||
import { NotificationItem } from '@/components/notifications/notification-item';
|
import { NotificationItem } from '@/components/notifications/notification-item';
|
||||||
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card';
|
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 {
|
interface NotificationListResponse {
|
||||||
data: Array<{
|
data: Array<{
|
||||||
@@ -66,6 +71,7 @@ export function Inbox() {
|
|||||||
const systemCritical = alertCount?.bySeverity.critical ?? 0;
|
const systemCritical = alertCount?.bySeverity.critical ?? 0;
|
||||||
const systemAlerts = alertList?.data ?? [];
|
const systemAlerts = alertList?.data ?? [];
|
||||||
const systemTop = systemAlerts.slice(0, 8);
|
const systemTop = systemAlerts.slice(0, 8);
|
||||||
|
const dismissAll = useDismissAll();
|
||||||
|
|
||||||
// ── Personal (notifications) ──
|
// ── Personal (notifications) ──
|
||||||
const { unreadCount: personalUnread } = useNotifications();
|
const { unreadCount: personalUnread } = useNotifications();
|
||||||
@@ -230,13 +236,25 @@ export function Inbox() {
|
|||||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Active alerts
|
Active alerts
|
||||||
</h4>
|
</h4>
|
||||||
<Link
|
<div className="flex items-center gap-3">
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
{systemAlerts.length > 0 ? (
|
||||||
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
|
<button
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
type="button"
|
||||||
>
|
onClick={() => dismissAll.mutate({})}
|
||||||
View all
|
disabled={dismissAll.isPending}
|
||||||
</Link>
|
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>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<ScrollArea className="max-h-[400px]">
|
<ScrollArea className="max-h-[400px]">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
Building2,
|
Building2,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
|
MailQuestion,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Home,
|
Home,
|
||||||
@@ -53,6 +54,7 @@ const MORE_GROUPS: MoreGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||||
|
{ label: 'Inquiries', icon: MailQuestion, segment: 'inquiries' },
|
||||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FileBarChart,
|
FileBarChart,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
MailQuestion,
|
||||||
Camera,
|
Camera,
|
||||||
Globe,
|
Globe,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -115,6 +116,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||||
|
{ href: `${base}/inquiries`, label: 'Inquiries', icon: MailQuestion },
|
||||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
||||||
{
|
{
|
||||||
href: `${base}/tenancies`,
|
href: `${base}/tenancies`,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const PERMISSION_CATALOG = {
|
|||||||
],
|
],
|
||||||
residential_clients: ['view', 'create', 'edit', 'delete'],
|
residential_clients: ['view', 'create', 'edit', 'delete'],
|
||||||
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
||||||
|
inquiries: ['view', 'manage'],
|
||||||
} as const satisfies {
|
} as const satisfies {
|
||||||
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
[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 = [
|
export const ALERT_RULES = [
|
||||||
'reservation.no_agreement',
|
'reservation.no_agreement',
|
||||||
'interest.stale',
|
'interest.stale',
|
||||||
|
'interest.no_activity',
|
||||||
'document.signer_overdue',
|
'document.signer_overdue',
|
||||||
'berth.under_offer_stalled',
|
'berth.under_offer_stalled',
|
||||||
'expense.duplicate',
|
'expense.duplicate',
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ export type RolePermissions = {
|
|||||||
delete: boolean;
|
delete: boolean;
|
||||||
change_stage: 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
|
* same form submission. Useful for reconciling: pick any submission
|
||||||
* here, look up the matching NocoDB row, confirm both halves agree. */
|
* here, look up the matching NocoDB row, confirm both halves agree. */
|
||||||
legacyNocodbId: text('legacy_nocodb_id'),
|
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. */
|
/** Capture-time metadata for debugging. */
|
||||||
sourceIp: text('source_ip'),
|
sourceIp: text('source_ip'),
|
||||||
userAgent: text('user_agent'),
|
userAgent: text('user_agent'),
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
|||||||
delete: true,
|
delete: true,
|
||||||
change_stage: true,
|
change_stage: true,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||||
@@ -167,6 +171,10 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
|||||||
delete: true,
|
delete: true,
|
||||||
change_stage: true,
|
change_stage: true,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||||
@@ -246,6 +254,10 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||||
@@ -325,6 +337,10 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VIEWER_PERMISSIONS: RolePermissions = {
|
export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||||
@@ -410,6 +426,10 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: {
|
||||||
|
view: true,
|
||||||
|
manage: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Residential Partner - for an outside party who handles residential
|
// Residential Partner - for an outside party who handles residential
|
||||||
@@ -498,4 +518,8 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: true,
|
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);
|
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 ─────────────────────────────────────────────────
|
// ─── reservation.no_agreement ─────────────────────────────────────────────────
|
||||||
// Active reservations > 3 days old that have no reservation_agreement document
|
// Active reservations > 3 days old that have no reservation_agreement document
|
||||||
// in any non-cancelled state.
|
// 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 ───────────────────────────────────────────────────────────
|
// ─── 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[]> {
|
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
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: interests.id,
|
id: interests.id,
|
||||||
@@ -97,25 +133,10 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(interests.portId, portId),
|
eq(interests.portId, portId),
|
||||||
inArray(interests.pipelineStage, STALE_STAGES),
|
inArray(interests.pipelineStage, ACTIVE_EARLY_STAGES),
|
||||||
isNull(interests.archivedAt),
|
isNull(interests.archivedAt),
|
||||||
// An interest can't be "stale for 14+ days" if it has only existed in
|
sql`${hasFollowupSql}`,
|
||||||
// THIS system for less than 14 days. Without this floor, a bulk import
|
sql`${lastTouchAtSql} < now() - interval '14 days'`,
|
||||||
// (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))),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,7 +144,7 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
|||||||
ruleId: 'interest.stale',
|
ruleId: 'interest.stale',
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
title: `Stale interest: ${r.clientName}`,
|
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}`,
|
link: `/[port]/interests/${r.id}`,
|
||||||
entityType: 'interest',
|
entityType: 'interest',
|
||||||
entityId: r.id,
|
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 ──────────────────────────────────────────────────
|
// ─── document.signer_overdue ──────────────────────────────────────────────────
|
||||||
// Pending signer for >14d, last reminder >7d ago (or never).
|
// 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.portId, portId),
|
||||||
eq(interests.leadCategory, 'hot_lead'),
|
eq(interests.leadCategory, 'hot_lead'),
|
||||||
isNull(interests.archivedAt),
|
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(
|
or(
|
||||||
lt(interests.dateLastContact, cutoff),
|
lt(interests.dateLastContact, cutoff),
|
||||||
and(isNull(interests.dateLastContact), lt(interests.updatedAt, 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> = {
|
export const RULE_REGISTRY: Record<AlertRuleId, RuleEvaluator> = {
|
||||||
'reservation.no_agreement': reservationNoAgreement,
|
'reservation.no_agreement': reservationNoAgreement,
|
||||||
'interest.stale': interestStale,
|
'interest.stale': interestStale,
|
||||||
|
'interest.no_activity': interestNoActivity,
|
||||||
'document.signer_overdue': documentSignerOverdue,
|
'document.signer_overdue': documentSignerOverdue,
|
||||||
'berth.under_offer_stalled': berthUnderOfferStalled,
|
'berth.under_offer_stalled': berthUnderOfferStalled,
|
||||||
'expense.duplicate': expenseDuplicate,
|
'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(
|
export async function acknowledgeAlert(
|
||||||
alertId: string,
|
alertId: string,
|
||||||
portId: 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.
|
-- 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_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_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_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)
|
, 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_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_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_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_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_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)
|
, 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)
|
, del_ports AS (DELETE FROM ports WHERE id IN (SELECT id FROM doomed) RETURNING 1)
|
||||||
SELECT 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();
|
await closeDb();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ export function makeFullPermissions(): RolePermissions {
|
|||||||
delete: true,
|
delete: true,
|
||||||
change_stage: true,
|
change_stage: true,
|
||||||
},
|
},
|
||||||
|
inquiries: { view: true, manage: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +473,7 @@ export function makeViewerPermissions(): RolePermissions {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: { view: true, manage: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +562,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
|||||||
delete: false,
|
delete: false,
|
||||||
change_stage: false,
|
change_stage: false,
|
||||||
},
|
},
|
||||||
|
inquiries: { view: true, manage: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +651,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
|||||||
delete: true,
|
delete: true,
|
||||||
change_stage: 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 { interests } from '@/lib/db/schema/interests';
|
||||||
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||||
import { documents } from '@/lib/db/schema/documents';
|
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 { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
||||||
import { makePort, makeClient, makeBerth, makeYacht } from '../helpers/factories';
|
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)));
|
.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', () => {
|
describe('alert engine', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -149,7 +175,7 @@ describe('alert engine', () => {
|
|||||||
expect(allRows[0]!.resolvedAt).not.toBeNull();
|
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 port = await makePort();
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
const stale = new Date(Date.now() - 30 * 86_400_000);
|
const stale = new Date(Date.now() - 30 * 86_400_000);
|
||||||
@@ -164,6 +190,9 @@ describe('alert engine', () => {
|
|||||||
updatedAt: stale,
|
updatedAt: stale,
|
||||||
})
|
})
|
||||||
.returning();
|
.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 clearAlerts(port.id);
|
||||||
await runAlertEngineForPorts([port.id]);
|
await runAlertEngineForPorts([port.id]);
|
||||||
@@ -172,6 +201,60 @@ describe('alert engine', () => {
|
|||||||
expect(open).toHaveLength(1);
|
expect(open).toHaveLength(1);
|
||||||
expect(open[0]!.entityId).toBe(interest!.id);
|
expect(open[0]!.entityId).toBe(interest!.id);
|
||||||
expect(open[0]!.severity).toBe('info');
|
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 () => {
|
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');
|
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 () => {
|
it('engine reports rule errors without crashing the sweep', async () => {
|
||||||
const port = await makePort();
|
const port = await makePort();
|
||||||
const summary = await runAlertEngineForPorts([port.id]);
|
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