Compare commits
30 Commits
4d018be800
...
cutover/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 866930c943 | |||
| 64a488dc15 | |||
| 2bc2cfac6f | |||
| 3f6f845c02 | |||
| fc994cd88b | |||
| e17476f3e3 | |||
| f4cfc5600f | |||
| 0ca9b2c3b5 | |||
| af05bb18dc | |||
| 1c91d76c52 | |||
| 352b2420b7 | |||
| 459c68a2c3 | |||
| adc9802361 | |||
| d8f739a7c2 | |||
| 93989b1e1d | |||
| 5b9560531e | |||
| f55be14813 | |||
| 6bc81270b9 | |||
| 38e392e38b | |||
| 039ef25fe5 | |||
| b3753b96a1 | |||
| 9147f2857e | |||
| 47778796ad | |||
| f7425d1231 | |||
| df8c26d1b3 | |||
| 91703bdb00 | |||
| 3165ec651f | |||
| 661187cc79 | |||
| 4dc0bdd8c4 | |||
| 7f04c765f4 |
@@ -51,8 +51,13 @@ const csp = [
|
||||
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
// https: so react-pdf/pdf.js can load its standard-font pack + branding fonts.
|
||||
"font-src 'self' data: https:",
|
||||
`connect-src 'self' ws: wss: https:${devConnectHosts}`,
|
||||
// PDF previews iframe a presigned storage URL; embedded-signing iframes the
|
||||
// Documenso host. Both are per-port/per-env, so allow https: (matching
|
||||
// img-src). frame-ancestors 'none' still blocks others from embedding us.
|
||||
"frame-src 'self' blob: https:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
|
||||
@@ -24,6 +24,28 @@ export default defineConfig({
|
||||
name: 'setup',
|
||||
testMatch: /smoke\/global-setup\.ts/,
|
||||
},
|
||||
{
|
||||
// Permission-matrix UX sweep. Users + roles are seeded separately via
|
||||
// `pnpm tsx tests/e2e/permissions/seed-permission-matrix.ts` (no global
|
||||
// setup dependency — relies on the already-seeded dev DB).
|
||||
name: 'permissions',
|
||||
testMatch: /permissions\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Lean role × viewport access matrix. Users pre-seeded (admin/director/
|
||||
// sales/viewer/residential_partner) — no global-setup dependency. Few
|
||||
// route compilations, so it stays under the dev-server OOM threshold.
|
||||
name: 'matrix',
|
||||
testMatch: /matrix\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smoke',
|
||||
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||
import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({
|
||||
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||
icon: FileSearch,
|
||||
},
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
|
||||
label: 'Price reconciliation',
|
||||
description:
|
||||
'Parse the purchase price from each berth’s current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
|
||||
icon: BadgeDollarSign,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table';
|
||||
|
||||
export default function BerthPriceReconcilePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berth price reconciliation"
|
||||
eyebrow="ADMIN"
|
||||
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
|
||||
/>
|
||||
<BerthPriceReconcileTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
|
||||
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { WarningCallout } from '@/components/ui/warning-callout';
|
||||
|
||||
// All field arrays removed - every Documenso setting now flows through
|
||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||
@@ -22,6 +23,35 @@ export default function DocumensoSettingsPage() {
|
||||
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
||||
/>
|
||||
|
||||
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
|
||||
<p>
|
||||
The CRM's signing features are built for Documenso 2.x (v2). Set the API version
|
||||
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
|
||||
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
|
||||
</p>
|
||||
<ul className="ms-4 mt-1 list-disc space-y-1">
|
||||
<li>
|
||||
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
|
||||
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Upload-and-send contracts / reservations</strong> fall back to v1's
|
||||
per-field placement: page size is assumed to be A4, and rich field metadata (required
|
||||
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
|
||||
</li>
|
||||
<li>
|
||||
<strong>One-call send with per-recipient signing links</strong>,{' '}
|
||||
<strong>sequential signing enforcement</strong>, and the{' '}
|
||||
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
|
||||
are unavailable or ignored on v1.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1">
|
||||
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
|
||||
the test-connection button to confirm.
|
||||
</p>
|
||||
</WarningCallout>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
|
||||
|
||||
export default async function ClientGroupDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string; groupId: string }>;
|
||||
}) {
|
||||
const { groupId } = await params;
|
||||
return <ClientGroupDetail groupId={groupId} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
|
||||
|
||||
export default function ClientGroupsPage() {
|
||||
return <ClientGroupsList />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt',
|
||||
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
||||
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
|
||||
// CM-6: manual-entry mode is resolved server-side so the client can skip
|
||||
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
|
||||
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
|
||||
return (
|
||||
<ScanShell
|
||||
logoUrl={branding?.logoUrl ?? null}
|
||||
portName={port?.name ?? null}
|
||||
manualEntry={ocr?.manualEntry ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const saveSchema = z.object({
|
||||
clearApiKey: z.boolean().optional(),
|
||||
useGlobal: z.boolean().optional(),
|
||||
aiEnabled: z.boolean().optional(),
|
||||
manualEntry: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
||||
@@ -58,6 +59,7 @@ export const PUT = withAuth(
|
||||
clearApiKey: body.clearApiKey,
|
||||
useGlobal: body.useGlobal,
|
||||
aiEnabled: body.aiEnabled,
|
||||
manualEntry: body.manualEntry,
|
||||
},
|
||||
ctx.userId,
|
||||
);
|
||||
|
||||
@@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
|
||||
|
||||
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||
|
||||
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent
|
||||
// signals. Gated on admin.view_audit_log - same permission the audit log
|
||||
// page uses.
|
||||
// The alert feed is entirely operational/deal signals (stale interest, hot lead
|
||||
// silent, EOI unsigned, signer overdue, reservation needs agreement, berth
|
||||
// stalled, duplicate/unscanned expense) — there are no audit/security alert
|
||||
// rules. Gated on interests.view so the operational roles that act on these
|
||||
// (sales, director, viewer) see them; external residential partners don't.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
|
||||
withPermission('interests', 'view', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
|
||||
|
||||
|
||||
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
|
||||
*
|
||||
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
|
||||
* integration tests can call it directly.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
approvals: z
|
||||
.array(
|
||||
z.object({
|
||||
berthId: z.string().min(1),
|
||||
price: z.number().nonnegative(),
|
||||
currency: z.string().min(1).max(8),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const postHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { postHandler } from './handlers';
|
||||
|
||||
export const POST = withAuth(withPermission('berths', 'edit', postHandler));
|
||||
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
|
||||
*
|
||||
* In handlers.ts so integration tests can call them directly, bypassing the
|
||||
* auth/permission middleware (per CLAUDE.md "Route handler exports").
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
|
||||
|
||||
export const getHandler: RouteHandler = async (_req, ctx) => {
|
||||
try {
|
||||
const data = await listPriceReconciliation(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('berths', 'edit', getHandler));
|
||||
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
archiveClientGroup,
|
||||
getClientGroupById,
|
||||
updateClientGroup,
|
||||
} from '@/lib/services/client-groups.service';
|
||||
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const group = await getClientGroupById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: group });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateClientGroupSchema);
|
||||
const updated = await updateClientGroup(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveClientGroup(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
|
||||
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const members = await listGroupMembers(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: members, total: members.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const { clientIds } = await parseBody(req, setGroupMembersSchema);
|
||||
await setGroupMembers(params.id!, ctx.portId, clientIds, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getMembersHandler, putMembersHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
|
||||
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));
|
||||
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));
|
||||
31
src/app/api/v1/client-groups/handlers.ts
Normal file
31
src/app/api/v1/client-groups/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
|
||||
import { createClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const groups = await listClientGroups(ctx.portId);
|
||||
return NextResponse.json({ data: groups, total: groups.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createClientGroupSchema);
|
||||
const group = await createClientGroup(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: group }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/route.ts
Normal file
6
src/app/api/v1/client-groups/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));
|
||||
@@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit';
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
'clients',
|
||||
'gdpr_export',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
|
||||
|
||||
@@ -26,8 +26,8 @@ export const GET = withAuth(
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
'clients',
|
||||
'gdpr_export',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, requestSchema);
|
||||
|
||||
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client');
|
||||
|
||||
export const GET = withAuth(withPermission('clients', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('clients', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler));
|
||||
@@ -48,6 +48,14 @@ export const POST = withAuth(
|
||||
}
|
||||
|
||||
const config = await getResolvedOcrConfig(ctx.portId);
|
||||
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
|
||||
// types the details by hand. The client should skip this route entirely
|
||||
// in manual mode, but we guard server-side too.
|
||||
if (config.manualEntry) {
|
||||
return NextResponse.json({
|
||||
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
|
||||
});
|
||||
}
|
||||
// Tesseract.js (in-browser) is the default. The server only invokes
|
||||
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
||||
// and (b) a key resolves. Otherwise the client falls back to its
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
|
||||
*
|
||||
* Sets or clears the deal-specific price override for one (interest, berth).
|
||||
* In handlers.ts so integration tests can call it directly.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
price: z.number().nonnegative().nullable(),
|
||||
currency: z.string().min(1).max(8).optional(),
|
||||
});
|
||||
|
||||
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
|
||||
req,
|
||||
ctx,
|
||||
params,
|
||||
) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
await setBerthPriceOverride(
|
||||
params.id!,
|
||||
params.berthId!,
|
||||
body.price,
|
||||
body.currency ?? null,
|
||||
ctx.portId,
|
||||
);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { putHandler } from './handlers';
|
||||
|
||||
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest');
|
||||
|
||||
export const GET = withAuth(withPermission('interests', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler));
|
||||
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht');
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('yachts', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler));
|
||||
@@ -30,6 +30,7 @@ interface ConfigResp {
|
||||
hasApiKey: boolean;
|
||||
useGlobal: boolean;
|
||||
aiEnabled: boolean;
|
||||
manualEntry: boolean;
|
||||
};
|
||||
models: Record<Provider, string[]>;
|
||||
}
|
||||
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
|
||||
// Key the body on the loaded payload so useState initializers seed
|
||||
// from server values cleanly.
|
||||
const sig = data?.data
|
||||
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}`
|
||||
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
|
||||
: 'loading';
|
||||
return (
|
||||
<SettingsBlockBody
|
||||
@@ -89,6 +90,7 @@ function SettingsBlockBody({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
|
||||
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
|
||||
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
|
||||
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
|
||||
null,
|
||||
);
|
||||
@@ -105,6 +107,7 @@ function SettingsBlockBody({
|
||||
clearApiKey: Boolean(clearApiKey),
|
||||
useGlobal: scope === 'global' ? false : useGlobal,
|
||||
aiEnabled: scope === 'global' ? false : aiEnabled,
|
||||
manualEntry: scope === 'global' ? false : manualEntry,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -190,6 +193,25 @@ function SettingsBlockBody({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === 'port' ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Checkbox
|
||||
id={`manualEntry-${scope}`}
|
||||
checked={manualEntry}
|
||||
onCheckedChange={(v) => setManualEntry(v === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
|
||||
Manual entry only (skip receipt scanning)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When on, staff just attach a receipt photo and type the details by hand - no
|
||||
on-device or AI parsing runs. Takes precedence over AI parsing above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
||||
|
||||
@@ -50,20 +50,25 @@ export function OnboardingBanner() {
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
||||
<span className="truncate">
|
||||
<strong>Setup is {data.percent}% complete</strong>. {data.completed} of {data.total} steps
|
||||
done.{' '}
|
||||
{next ? (
|
||||
<>
|
||||
Next:{' '}
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${next.href}` as any}
|
||||
className="font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{next.label}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
<strong>Setup is {data.percent}% complete</strong>
|
||||
{/* Verbose progress + the "Next:" deep-link are hidden on mobile,
|
||||
where they get clipped (R1) and duplicate the always-visible
|
||||
"View checklist" button. Shown from sm: up. */}
|
||||
<span className="hidden sm:inline">
|
||||
. {data.completed} of {data.total} steps done.{' '}
|
||||
{next ? (
|
||||
<>
|
||||
Next:{' '}
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${next.href}` as any}
|
||||
className="font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{next.label}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
|
||||
@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'assignment_enabled',
|
||||
label: 'Interest Assignment',
|
||||
description:
|
||||
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'tenancies_module_enabled',
|
||||
label: 'Tenancies Module',
|
||||
@@ -141,9 +149,17 @@ const KNOWN_SETTINGS: Array<{
|
||||
},
|
||||
{
|
||||
key: 'inquiry_notification_recipients',
|
||||
label: 'Berth & contact inquiry alerts',
|
||||
label: 'Berth inquiry alerts',
|
||||
description:
|
||||
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||
'Who receives staff alerts for new berth inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'contact_notification_recipients',
|
||||
label: 'Contact-form alerts',
|
||||
description:
|
||||
'Who receives staff alerts for new website contact-form submissions: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Falls back to Inquiry Contact Email when empty.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
@@ -155,6 +171,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'signing_notification_recipients',
|
||||
label: 'Document signing alerts',
|
||||
description:
|
||||
'Who gets emailed each time a party signs an EOI / contract and when a document is fully signed: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Add yourself and sales@ here. Falls back to the Reply-To address when empty.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'eoi_signers',
|
||||
label: 'EOI Signers',
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatRole } from '@/lib/constants';
|
||||
import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
@@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
enabled: open,
|
||||
});
|
||||
const roles = rolesQuery.data?.data ?? [];
|
||||
// Hide retired/owner-only system roles from the picker, but always keep the
|
||||
// role the user being edited already holds so their record stays editable.
|
||||
const selectableRoles = roles.filter(
|
||||
(r) => !NON_ASSIGNABLE_ROLE_NAMES.has(r.name) || r.id === user?.role.id,
|
||||
);
|
||||
const [firstName, setFirstName] = useState(initialNames.first);
|
||||
const [lastName, setLastName] = useState(initialNames.last);
|
||||
const [email, setEmail] = useState(user?.email ?? '');
|
||||
const [originalEmail] = useState(user?.email ?? '');
|
||||
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
// New users: email them a set-password link by default rather than typing a
|
||||
// password here. Toggle off to set one manually.
|
||||
const [sendSetupEmail, setSendSetupEmail] = useState(true);
|
||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
||||
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
||||
@@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
email,
|
||||
password,
|
||||
// Email mode omits the password entirely; manual mode sends it.
|
||||
password: sendSetupEmail ? undefined : password,
|
||||
sendSetupEmail,
|
||||
displayName,
|
||||
phone: phoneE164 ?? undefined,
|
||||
roleId,
|
||||
@@ -250,18 +260,37 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-password">Password</Label>
|
||||
<Input
|
||||
id="user-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 12 characters"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label htmlFor="user-setup-email">Email a set-password link</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The user gets an email to choose their own password. Turn off to set one
|
||||
here instead.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="user-setup-email"
|
||||
checked={sendSetupEmail}
|
||||
onCheckedChange={setSendSetupEmail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!sendSetupEmail && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-password">Password</Label>
|
||||
<Input
|
||||
id="user-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 12 characters"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
{selectableRoles.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{formatRole(r.name)}
|
||||
</SelectItem>
|
||||
|
||||
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Bulk berth price-reconcile table (CM-2 Part A).
|
||||
*
|
||||
* Lists the price parsed from each berth's current spec sheet next to the stored
|
||||
* price, with per-row + select-all approval. Nothing is written until the rep
|
||||
* approves — the apply mutation posts only the checked, changed rows.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
interface Row {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
currentPrice: number | null;
|
||||
currentCurrency: string;
|
||||
parsedPrice: number | null;
|
||||
parsedCurrency: string | null;
|
||||
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<Row['status'], string> = {
|
||||
changed: 'bg-amber-100 text-amber-800',
|
||||
matched: 'bg-muted text-muted-foreground',
|
||||
needs_review: 'bg-red-100 text-red-700',
|
||||
no_pdf: 'bg-slate-100 text-slate-500',
|
||||
};
|
||||
const STATUS_LABEL: Record<Row['status'], string> = {
|
||||
changed: 'Changed',
|
||||
matched: 'Matched',
|
||||
needs_review: 'Needs review',
|
||||
no_pdf: 'No PDF',
|
||||
};
|
||||
|
||||
const fmt = (n: number | null, ccy: string | null) =>
|
||||
n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim();
|
||||
|
||||
export function BerthPriceReconcileTable() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery<{ data: Row[] }>({
|
||||
queryKey: ['berths', 'price-reconcile'],
|
||||
queryFn: () => apiFetch('/api/v1/berths/price-reconcile'),
|
||||
});
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]);
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: async (): Promise<{ data: { updated: number } }> => {
|
||||
const approvals = selectable
|
||||
.filter((r) => checked[r.berthId] && r.parsedPrice != null)
|
||||
.map((r) => ({
|
||||
berthId: r.berthId,
|
||||
price: r.parsedPrice as number,
|
||||
currency: r.parsedCurrency ?? r.currentCurrency,
|
||||
}));
|
||||
return apiFetch('/api/v1/berths/price-reconcile/apply', {
|
||||
method: 'POST',
|
||||
body: { approvals },
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Updated ${res.data.updated} berth price(s).`);
|
||||
setChecked({});
|
||||
void qc.invalidateQueries({ queryKey: ['berths'] });
|
||||
},
|
||||
onError: (e: Error) => toastError(e),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets…</p>;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
|
||||
);
|
||||
}
|
||||
|
||||
const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]);
|
||||
const selectedCount = selectable.filter((r) => checked[r.berthId]).length;
|
||||
const reviewCount = rows.filter((r) => r.status === 'needs_review').length;
|
||||
const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selectedCount === 0 || apply.isPending}
|
||||
onClick={() => apply.mutate()}
|
||||
>
|
||||
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
|
||||
<th className="w-10 p-2 ps-3">
|
||||
<Checkbox
|
||||
aria-label="Select all changed"
|
||||
checked={allChecked}
|
||||
onCheckedChange={(c) =>
|
||||
setChecked(
|
||||
c === true
|
||||
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
|
||||
: {},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-2">Mooring</th>
|
||||
<th className="p-2">Area</th>
|
||||
<th className="p-2 text-end">Current</th>
|
||||
<th className="p-2 text-end">Parsed</th>
|
||||
<th className="p-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.berthId} className="border-b last:border-0">
|
||||
<td className="p-2 ps-3">
|
||||
{r.status === 'changed' ? (
|
||||
<Checkbox
|
||||
aria-label={`Approve ${r.mooringNumber}`}
|
||||
checked={!!checked[r.berthId]}
|
||||
onCheckedChange={(c) =>
|
||||
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-2 font-medium">{r.mooringNumber}</td>
|
||||
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
|
||||
<td className="p-2 text-end tabular-nums">
|
||||
{fmt(r.currentPrice, r.currentCurrency)}
|
||||
</td>
|
||||
<td className="p-2 text-end tabular-nums">
|
||||
{fmt(r.parsedPrice, r.parsedCurrency)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
{r.warning ? (
|
||||
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
src/components/client-groups/client-group-detail.tsx
Normal file
304
src/components/client-groups/client-group-detail.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface GroupMember {
|
||||
clientId: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
}
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
fullName: string;
|
||||
primaryEmail: string | null;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, successMsg: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMsg);
|
||||
} catch {
|
||||
toast.error('Copy failed — clipboard unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
export function ClientGroupDetail({ groupId }: { groupId: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
|
||||
queryKey: ['client-group', groupId],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
|
||||
});
|
||||
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
|
||||
queryKey: ['client-group', groupId, 'members'],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
|
||||
});
|
||||
|
||||
const group = groupResp?.data;
|
||||
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
|
||||
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Group archived');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
router.push(`/${portSlug}/client-groups` as Route);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/${portSlug}/client-groups` as Route}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
All groups
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={group?.name ?? 'Group'}
|
||||
eyebrow="Mailing group"
|
||||
kpiLine={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||
{emails.length < members.length ? (
|
||||
<span className="text-amber-700">
|
||||
· {members.length - emails.length} without email
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={emails.length === 0}
|
||||
onClick={() =>
|
||||
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
|
||||
}
|
||||
>
|
||||
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Copy all emails
|
||||
</Button>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button variant="outline" onClick={() => setManageOpen(true)}>
|
||||
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Manage members
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
|
||||
archive.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Archive
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading members…</p>
|
||||
) : members.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No members yet"
|
||||
description="Use “Manage members” to add clients to this group."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Client</th>
|
||||
<th className="px-4 py-2 font-medium">Email</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{members.map((m) => (
|
||||
<tr key={m.clientId} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${m.clientId}` as Route}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{m.fullName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-end">
|
||||
{m.email ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(m.email!, 'Email copied')}
|
||||
aria-label={`Copy ${m.email}`}
|
||||
title="Copy email"
|
||||
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manageOpen ? (
|
||||
<ManageMembersDialog
|
||||
groupId={groupId}
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
currentIds={members.map((m) => m.clientId)}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageMembersDialog({
|
||||
groupId,
|
||||
open,
|
||||
onOpenChange,
|
||||
currentIds,
|
||||
onSaved,
|
||||
}: {
|
||||
groupId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
currentIds: string[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
|
||||
queryKey: ['clients', 'group-picker'],
|
||||
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const clients = data?.data ?? [];
|
||||
const filtered = clients.filter((c) =>
|
||||
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
|
||||
method: 'PUT',
|
||||
body: { clientIds: Array.from(selected) },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Members updated');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tick the clients who belong in this group. {selected.size} selected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Search clients…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{isLoading ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">Loading clients…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
|
||||
{c.primaryEmail ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{c.primaryEmail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Save members
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
170
src/components/client-groups/client-groups-list.tsx
Normal file
170
src/components/client-groups/client-groups-list.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface ClientGroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export function ClientGroupsList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [color, setColor] = useState('#6B7280');
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
|
||||
queryKey: ['client-groups'],
|
||||
queryFn: () => apiFetch('/api/v1/client-groups'),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/client-groups', {
|
||||
method: 'POST',
|
||||
body: { name: name.trim(), description: description.trim() || null, color },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Group created');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setColor('#6B7280');
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const groups = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Client Groups"
|
||||
eyebrow="Mailing"
|
||||
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
|
||||
variant="gradient"
|
||||
actions={
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||
New group
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No groups yet"
|
||||
description="Create a group to start organising clients into mailing lists."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${portSlug}/client-groups/${g.id}` as Route}
|
||||
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: g.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
|
||||
</div>
|
||||
{g.description ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
|
||||
) : null}
|
||||
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New client group</DialogTitle>
|
||||
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-name">Name</Label>
|
||||
<Input
|
||||
id="cg-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Newsletter subscribers"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="cg-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-color">Color</Label>
|
||||
<input
|
||||
id="cg-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
|
||||
Create group
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,9 @@
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import { useState } from 'react';
|
||||
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
const primaryEmail =
|
||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
||||
client.contacts?.find((c) => c.channel === 'email')?.value;
|
||||
const primaryPhoneContact =
|
||||
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
||||
client.contacts?.find((c) => c.channel === 'phone');
|
||||
const primaryPhone = primaryPhoneContact?.value;
|
||||
// wa.me requires the E.164 number without the leading "+". Strip from the
|
||||
// canonical E.164 form when available; otherwise strip non-digits from the
|
||||
// display value as a best-effort fallback.
|
||||
const whatsappNumber = primaryPhoneContact?.valueE164
|
||||
? primaryPhoneContact.valueE164.replace(/^\+/, '')
|
||||
: primaryPhoneContact?.value
|
||||
? primaryPhoneContact.value.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
const addedLabel = client.createdAt
|
||||
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{primaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{primaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
||||
<Phone />
|
||||
Call
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{whatsappNumber ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`https://wa.me/${whatsappNumber}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
||||
>
|
||||
<WhatsAppIcon className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{!isArchived && client.clientPortalEnabled === true ? (
|
||||
{/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
|
||||
request. GDPR export moved to the top-right action cluster.
|
||||
Portal-invite stays as the one primary CTA here. */}
|
||||
{!isArchived && client.clientPortalEnabled === true ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<div className="hidden sm:inline-flex">
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
defaultEmail={primaryEmail}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hidden sm:inline-flex">
|
||||
<GdprExportButton clientId={client.id} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
right perm) permanently-delete. Destructive actions sit out
|
||||
of the primary action flow. */}
|
||||
<div className="flex items-start gap-1">
|
||||
{/* CM-4: GDPR export relocated here as a compact icon trigger,
|
||||
alongside reminder/archive/delete. Self-gates on permission. */}
|
||||
<GdprExportButton clientId={client.id} variant="icon" />
|
||||
{isArchived && (
|
||||
<PermissionGate resource="admin" action="permanently_delete_clients">
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
||||
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
||||
@@ -156,6 +157,9 @@ function OverviewTab({
|
||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||
</div>
|
||||
|
||||
{/* CM-9: point-of-contact (default level for the client). */}
|
||||
<ProxyCard entityType="client" entityId={clientId} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -48,14 +48,22 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
export function GdprExportButton({
|
||||
clientId,
|
||||
variant = 'button',
|
||||
}: {
|
||||
clientId: string;
|
||||
/** `button` = standalone outline button (default). `icon` = compact icon-only
|
||||
* trigger for the detail-header top-right action cluster (CM-4). */
|
||||
variant?: 'button' | 'icon';
|
||||
}) {
|
||||
const { can, isSuperAdmin } = usePermissions();
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [emailToClient, setEmailToClient] = useState(false);
|
||||
const [emailOverride, setEmailOverride] = useState('');
|
||||
|
||||
const allowed = isSuperAdmin || can('admin', 'manage_settings');
|
||||
const allowed = isSuperAdmin || can('clients', 'gdpr_export');
|
||||
|
||||
const queryKey = ['gdpr-exports', clientId];
|
||||
const { data, isLoading } = useQuery<ListResp>({
|
||||
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
GDPR export
|
||||
</Button>
|
||||
{variant === 'icon' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="GDPR export"
|
||||
title="GDPR export"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<FileDown className="size-4" aria-hidden />
|
||||
</button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
GDPR export
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -104,7 +104,7 @@ export function FilePreviewDialog({
|
||||
|
||||
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
|
||||
// request is gated on the dialog being open and a fileId being set.
|
||||
const previewQuery = useQuery<{ data: { url: string } }>({
|
||||
const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
|
||||
queryKey: ['file-preview', fileId],
|
||||
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
|
||||
enabled: open && !!fileId,
|
||||
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
|
||||
const loading = previewQuery.isLoading;
|
||||
const error = previewQuery.error ? 'Failed to load preview' : null;
|
||||
|
||||
const kind = previewKindFor(mimeType, fileName);
|
||||
// Prefer the caller-supplied mime, but fall back to the server's resolved
|
||||
// mime (getPreviewUrl returns it). Without this, callers that pass only a
|
||||
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
|
||||
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
|
||||
// through to the "unknown" surface even though the server knows it's a PDF.
|
||||
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
|
||||
const kind = previewKindFor(resolvedMime, fileName);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||
import { useAlertCount } from '@/components/alerts/use-alerts';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
/**
|
||||
* Merged "Inbox" surface - replaces the previously-separate /alerts and
|
||||
@@ -29,6 +30,11 @@ export function InboxPageShell() {
|
||||
const [alertsOpen, setAlertsOpen] = useState(true);
|
||||
const [remindersOpen, setRemindersOpen] = useState(true);
|
||||
const { data: alertCount } = useAlertCount();
|
||||
// The deal-alert feed (stale interests, overdue signers, …) is gated on
|
||||
// interests.view — operational roles see it; external residential partners
|
||||
// don't. Hide the whole section rather than letting its query 403.
|
||||
const { can } = usePermissions();
|
||||
const canSeeAlerts = can('interests', 'view');
|
||||
|
||||
// localStorage hydration on mount - canonical "read from external
|
||||
// store" pattern. setState in effect is intentional.
|
||||
@@ -95,20 +101,22 @@ export function InboxPageShell() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Alerts"
|
||||
count={activeAlerts}
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
/>
|
||||
{alertsOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<AlertsPageShell embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
{canSeeAlerts ? (
|
||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Alerts"
|
||||
count={activeAlerts}
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
/>
|
||||
{alertsOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<AlertsPageShell embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
|
||||
dismissed: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
|
||||
open: 'Open',
|
||||
assigned: 'Assigned',
|
||||
converted: 'Converted',
|
||||
dismissed: 'Dismissed',
|
||||
};
|
||||
|
||||
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||||
{ id: 'contactEmail', label: 'Email' },
|
||||
{ id: 'kind', label: 'Type' },
|
||||
@@ -114,7 +121,7 @@ export function getInquiryColumns({
|
||||
const state = row.original.triageState;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge className={TRIAGE_TONE[state]}>{state}</Badge>
|
||||
<Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
|
||||
{row.original.convertedInterestId ? (
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
|
||||
|
||||
@@ -8,8 +8,10 @@ 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 { usePermissions } from '@/hooks/use-permissions';
|
||||
import {
|
||||
KIND_LABELS,
|
||||
TRIAGE_LABELS,
|
||||
TRIAGE_TONE,
|
||||
type InquiryKind,
|
||||
type InquiryTriageState,
|
||||
@@ -49,6 +51,7 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
export function InquiryDetail({ id }: { id: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const { isSuperAdmin } = usePermissions();
|
||||
|
||||
const { data, isLoading, error } = useQuery<InquiryDetailData>({
|
||||
queryKey: ['inquiries', id],
|
||||
@@ -74,6 +77,29 @@ export function InquiryDetail({ id }: { id: string }) {
|
||||
|
||||
const p = (data?.payload ?? {}) as Record<string, unknown>;
|
||||
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
|
||||
// Read a payload value that may be a string[] (e.g. residence_types, the
|
||||
// contact form's interest[]) OR a lone string, and present it comma-joined.
|
||||
const list = (k: string): string => {
|
||||
const v = p[k];
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string').join(', ');
|
||||
return typeof v === 'string' ? v : '';
|
||||
};
|
||||
// The free-text message a lead left. Website forms use different keys
|
||||
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
|
||||
// common ones and surface it for every inquiry kind.
|
||||
const comment = str('comments') || str('message') || str('comment') || str('notes');
|
||||
// Preferred method of contact (register form: 'email' | 'phone'). Surfaced so
|
||||
// reps honour the lead's stated contact request.
|
||||
const preferredContactRaw = str('method_of_contact').toLowerCase();
|
||||
const preferredContact =
|
||||
preferredContactRaw === 'email'
|
||||
? 'Email'
|
||||
: preferredContactRaw === 'phone'
|
||||
? 'Phone call back'
|
||||
: '';
|
||||
const residenceTypes = list('residence_types');
|
||||
// Contact-form "type of interest" (owner/broker/investor/…), stored as an array.
|
||||
const contactInterest = list('interest');
|
||||
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
@@ -84,11 +110,20 @@ export function InquiryDetail({ id }: { id: string }) {
|
||||
<Row label="Name" value={data?.contactName} />
|
||||
<Row label="Email" value={data?.contactEmail} />
|
||||
<Row label="Phone" value={str('phone')} />
|
||||
{data?.kind === 'residence_inquiry' ? (
|
||||
<Row label="Residence type(s)" value={residenceTypes} />
|
||||
) : null}
|
||||
{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}
|
||||
{data?.kind === 'contact_form' && contactInterest ? (
|
||||
<Row label="Type of interest" value={contactInterest} />
|
||||
) : null}
|
||||
{preferredContact ? <Row label="Preferred contact" value={preferredContact} /> : null}
|
||||
{comment ? (
|
||||
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
|
||||
) : 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} />
|
||||
@@ -107,7 +142,9 @@ export function InquiryDetail({ id }: { id: string }) {
|
||||
label="Status"
|
||||
value={
|
||||
data ? (
|
||||
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge>
|
||||
<Badge className={TRIAGE_TONE[data.triageState]}>
|
||||
{TRIAGE_LABELS[data.triageState]}
|
||||
</Badge>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
@@ -155,7 +192,7 @@ export function InquiryDetail({ id }: { id: string }) {
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
];
|
||||
].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
@@ -166,7 +203,9 @@ export function InquiryDetail({ id }: { id: string }) {
|
||||
<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>
|
||||
<Badge className={TRIAGE_TONE[data.triageState]}>
|
||||
{TRIAGE_LABELS[data.triageState]}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
|
||||
@@ -11,14 +11,11 @@ import {
|
||||
Trophy,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Mail,
|
||||
MessageSquarePlus,
|
||||
Phone,
|
||||
AlarmClock,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip';
|
||||
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
||||
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { formatOutcome } from '@/lib/constants';
|
||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
/** Primary contact channels resolved from the linked client. The header
|
||||
* uses these to render Email / Call / WhatsApp buttons so the rep
|
||||
* doesn't have to navigate to the client page just to reach out. */
|
||||
/** Primary contact channels resolved from the linked client. The
|
||||
* Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
|
||||
* for downstream reuse (e.g. proxy comms routing, CM-9). */
|
||||
clientPrimaryEmail?: string | null;
|
||||
clientPrimaryPhone?: string | null;
|
||||
clientPrimaryPhoneE164?: string | null;
|
||||
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
const [logContactOpen, setLogContactOpen] = useState(false);
|
||||
const [reminderOpen, setReminderOpen] = useState(false);
|
||||
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
|
||||
// CM-5: assignment UI is hidden when the per-port toggle is off (default).
|
||||
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
|
||||
|
||||
const isArchived = !!interest.archivedAt;
|
||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||
const isClosed = !!interest.outcome;
|
||||
|
||||
// Contact deep-links - resolved from the linked client's primary channels.
|
||||
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
||||
// stripping non-digits from the display value when the canonical form is
|
||||
// missing.
|
||||
const whatsappNumber = interest.clientPrimaryPhoneE164
|
||||
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
|
||||
: interest.clientPrimaryPhone
|
||||
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const reopenMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
{interest.activeReminderCount}
|
||||
</span>
|
||||
) : null}
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<AssignedToChip
|
||||
interestId={interest.id}
|
||||
currentAssignedTo={interest.assignedTo ?? null}
|
||||
currentAssignedToName={interest.assignedToName ?? null}
|
||||
/>
|
||||
</PermissionGate>
|
||||
{assignmentEnabled ? (
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<AssignedToChip
|
||||
interestId={interest.id}
|
||||
currentAssignedTo={interest.assignedTo ?? null}
|
||||
currentAssignedToName={interest.assignedToName ?? null}
|
||||
/>
|
||||
</PermissionGate>
|
||||
) : null}
|
||||
<MultiEoiChip interestId={interest.id} />
|
||||
<DealPulseChip
|
||||
interest={{
|
||||
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact deep-links - let the rep email / call / WhatsApp the
|
||||
client without leaving the interest workspace. Resolved from
|
||||
the linked client's primary contact channels (server-side
|
||||
fetch in getInterestById). */}
|
||||
{interest.clientPrimaryEmail ||
|
||||
interest.clientPrimaryPhone ||
|
||||
whatsappNumber ||
|
||||
interest.clientId ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientId ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
||||
aria-label="Open client page"
|
||||
>
|
||||
<User />
|
||||
Client page
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{interest.clientPrimaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`mailto:${interest.clientPrimaryEmail}`}
|
||||
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
||||
>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{interest.clientPrimaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`tel:${interest.clientPrimaryPhone}`}
|
||||
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
||||
>
|
||||
<Phone />
|
||||
Call
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{whatsappNumber ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`https://wa.me/${whatsappNumber}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message on WhatsApp`}
|
||||
>
|
||||
<WhatsAppIcon className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
|
||||
Client-page link + Log-contact action stay - the rep can still
|
||||
jump to the client and record outreach without leaving here. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientId ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
onClick={() => setLogContactOpen(true)}
|
||||
aria-label="Log a contact for this interest"
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
Log contact
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
||||
aria-label="Open client page"
|
||||
>
|
||||
<User />
|
||||
Client page
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
onClick={() => setLogContactOpen(true)}
|
||||
aria-label="Log a contact for this interest"
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
Log contact
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||
|
||||
@@ -868,10 +868,15 @@ function SignedEoiCard({
|
||||
* the file in a new tab via the alongside View button for full-screen.
|
||||
*/
|
||||
function SignedPdfPreview({ fileId }: { fileId: string }) {
|
||||
const { data, isLoading, isError } = useQuery<{ data: { url: string; filename: string } }>({
|
||||
queryKey: ['files', fileId, 'download-url'],
|
||||
// Use the PREVIEW endpoint, not /download: /download presigns with the
|
||||
// filename so S3 returns `Content-Disposition: attachment`, which makes the
|
||||
// iframe trigger a file download (blank preview) instead of rendering. The
|
||||
// preview endpoint presigns WITHOUT a filename → inline disposition → the
|
||||
// browser's native PDF viewer renders it in the card.
|
||||
const { data, isLoading, isError } = useQuery<{ data: { url: string; mimeType: string } }>({
|
||||
queryKey: ['files', fileId, 'preview-url'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`),
|
||||
apiFetch<{ data: { url: string; mimeType: string } }>(`/api/v1/files/${fileId}/preview`),
|
||||
// Presigned URL TTLs vary per backend - refresh well before they
|
||||
// expire so a long-open card doesn't suddenly 403. 4 minutes is
|
||||
// comfortably below the 5-minute MinIO default.
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
||||
@@ -848,7 +849,18 @@ function OverviewTab({
|
||||
deposit_paid: 'deposit',
|
||||
contract: 'contract',
|
||||
};
|
||||
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
|
||||
const stageOwnedMilestoneRaw =
|
||||
STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
|
||||
// B2 (2026-06-18): if the stage-owned milestone is already COMPLETE — e.g. a
|
||||
// migrated deal left at stage=eoi with a signed EOI that never auto-advanced —
|
||||
// don't pin it as the current "NEXT STEP". Falling back to null makes phaseFor
|
||||
// use completion ordering, so the signed milestone shows as done/past and the
|
||||
// next incomplete one (Reservation) becomes current. Display-only; the
|
||||
// pipeline_stage column is unchanged.
|
||||
const stageOwnedMilestoneComplete = stageOwnedMilestoneRaw
|
||||
? milestoneCompletion[stageOwnedMilestoneRaw]
|
||||
: false;
|
||||
const stageOwnedMilestone = stageOwnedMilestoneComplete ? null : stageOwnedMilestoneRaw;
|
||||
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
|
||||
const phaseFor = (k: (typeof order)[number]): Phase => {
|
||||
// Stage owns this milestone → always current, never collapsed.
|
||||
@@ -1122,6 +1134,9 @@ function OverviewTab({
|
||||
archivedAt={null}
|
||||
/>
|
||||
|
||||
{/* CM-9: per-deal point-of-contact (overrides the client's default). */}
|
||||
<ProxyCard entityType="interest" entityId={interestId} />
|
||||
|
||||
{/* Qualification checklist - surfaces the port's per-port criteria so
|
||||
the rep can mark each one confirmed before the deal advances out
|
||||
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
|
||||
addedBy: string | null;
|
||||
addedAt: string;
|
||||
notes: string | null;
|
||||
priceOverride: string | null;
|
||||
priceOverrideCurrency: string | null;
|
||||
mooringNumber: string | null;
|
||||
area: string | null;
|
||||
status: string;
|
||||
@@ -193,6 +195,24 @@ function useRemoveLink(interestId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// CM-2 Part B: set/clear the deal-specific price override for one berth.
|
||||
function useSetBerthPrice(interestId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { berthId: string; price: number | null }) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
|
||||
method: 'PUT',
|
||||
body: { price: args.price },
|
||||
}),
|
||||
onSuccess: (_data, args) => {
|
||||
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
|
||||
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
},
|
||||
onError: (e: Error) => toastError(e),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bypass dialog ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BypassDialogProps {
|
||||
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
|
||||
}: RowProps) {
|
||||
const [bypassOpen, setBypassOpen] = useState(false);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
|
||||
const setBerthPrice = useSetBerthPrice(interestId);
|
||||
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
|
||||
const showBypassControl = eoiStatus === 'signed';
|
||||
|
||||
const commitPrice = () => {
|
||||
const raw = priceDraft.replace(/[,\s]/g, '');
|
||||
const next = raw === '' ? null : Number(raw);
|
||||
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
|
||||
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
|
||||
if (next === prev) return;
|
||||
setBerthPrice.mutate({ berthId: row.berthId, price: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
|
||||
this interest only; flows into the EOI/document {{berth.price}} token. */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">Deal price</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overrides the berth's list price for this deal only. Leave blank to use the list
|
||||
price.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
|
||||
placeholder="List price"
|
||||
value={priceDraft}
|
||||
disabled={isPending || setBerthPrice.isPending}
|
||||
onChange={(e) => setPriceDraft(e.target.value)}
|
||||
onBlur={commitPrice}
|
||||
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
|
||||
/>
|
||||
{row.priceOverrideCurrency ? (
|
||||
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBypassControl ? (
|
||||
// Bypass section reads as a third toggle-style row: label + description
|
||||
// on the left, action button inline with the description so it doesn't
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Topbar } from '@/components/layout/topbar';
|
||||
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
|
||||
@@ -112,6 +114,30 @@ export function AppShell({
|
||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
|
||||
|
||||
// Residential lockdown: a residential-only user (residential access, no
|
||||
// marina `clients.view`) must never see marina pages — including the marina
|
||||
// dashboard. The API already 403s their data; this guard blocks the *routes*,
|
||||
// redirecting any non-residential path to their residential home. Personal
|
||||
// surfaces (settings, inbox) stay reachable.
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { can } = usePermissions();
|
||||
const residentialOnly =
|
||||
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
|
||||
useEffect(() => {
|
||||
if (!residentialOnly || !pathname) return;
|
||||
const [portSeg, ...rest] = pathname.split('/').filter(Boolean);
|
||||
const sub = rest.join('/');
|
||||
const allowed =
|
||||
sub === '' ||
|
||||
sub.startsWith('residential') ||
|
||||
sub.startsWith('settings') ||
|
||||
sub.startsWith('inbox');
|
||||
if (!allowed && portSeg) {
|
||||
router.replace(`/${portSeg}/residential/clients`);
|
||||
}
|
||||
}, [residentialOnly, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const mqMobile = window.matchMedia(MOBILE_QUERY);
|
||||
const mqTablet = window.matchMedia(TABLET_QUERY);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
|
||||
import { Anchor, ClipboardList, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
type TabSpec = {
|
||||
label: string;
|
||||
@@ -12,16 +13,21 @@ type TabSpec = {
|
||||
segment: string; // route segment after /[portSlug]/
|
||||
};
|
||||
|
||||
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
|
||||
// Search occupies the center slot. Documents demoted to the MoreSheet -
|
||||
// reps reach docs less often than berths during a walking inventory check,
|
||||
// and pinned-to-client documents are accessed via the client detail anyway.
|
||||
const TABS_LEFT: TabSpec[] = [
|
||||
// Marina users: Dashboard, Clients | Berths. Search center, More right.
|
||||
const MARINA_TABS_LEFT: TabSpec[] = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||
];
|
||||
const MARINA_TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
|
||||
|
||||
const TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
|
||||
// Residential-only users (e.g. residential partners) never have marina access,
|
||||
// so the bottom tabs mirror their residential-only sidebar instead of showing
|
||||
// Clients/Berths they 403 on (matches the AppShell route lockdown).
|
||||
const RESIDENTIAL_TABS_LEFT: TabSpec[] = [
|
||||
{ label: 'Clients', icon: Users, segment: 'residential/clients' },
|
||||
{ label: 'Interests', icon: ClipboardList, segment: 'residential/interests' },
|
||||
];
|
||||
const RESIDENTIAL_TABS_RIGHT: TabSpec[] = [];
|
||||
|
||||
interface MobileBottomTabsProps {
|
||||
onMoreClick: () => void;
|
||||
@@ -31,6 +37,11 @@ interface MobileBottomTabsProps {
|
||||
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
|
||||
const pathname = usePathname();
|
||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||||
const { can, isSuperAdmin } = usePermissions();
|
||||
const residentialOnly =
|
||||
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
|
||||
const tabsLeft = residentialOnly ? RESIDENTIAL_TABS_LEFT : MARINA_TABS_LEFT;
|
||||
const tabsRight = residentialOnly ? RESIDENTIAL_TABS_RIGHT : MARINA_TABS_RIGHT;
|
||||
|
||||
function isActive(segment: string): boolean {
|
||||
return pathname.startsWith(`/${portSlug}/${segment}`);
|
||||
@@ -46,7 +57,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
|
||||
'flex items-end',
|
||||
)}
|
||||
>
|
||||
{TABS_LEFT.map((tab) => (
|
||||
{tabsLeft.map((tab) => (
|
||||
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
||||
))}
|
||||
|
||||
@@ -60,7 +71,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
|
||||
<span className="relative font-medium">Search</span>
|
||||
</button>
|
||||
|
||||
{TABS_RIGHT.map((tab) => (
|
||||
{tabsRight.map((tab) => (
|
||||
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
||||
))}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
|
||||
|
||||
/**
|
||||
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
|
||||
* deep-navy gradient surface with white type, the brand "PN" mark on the
|
||||
* left when there's no back affordance, and a soft glow shadow underneath
|
||||
* for depth instead of a hard divider line.
|
||||
* deep-navy gradient surface with white type, a back arrow on the left when
|
||||
* there's a back affordance (otherwise a balancing spacer), and a soft glow
|
||||
* shadow underneath for depth instead of a hard divider line.
|
||||
*
|
||||
* Slots: title (auto-truncating), back arrow, primary action - all driven by
|
||||
* `useMobileChrome()` from the active page. When no page has set a title the
|
||||
@@ -47,17 +47,6 @@ export function MobileTopbar() {
|
||||
portTitle ||
|
||||
'CRM';
|
||||
|
||||
// Brand-mark initials derived from the port slug
|
||||
// ("port-nimara" → "PN", "marina-alpha" → "MA"). Cheap, self-contained,
|
||||
// no extra DB round-trip.
|
||||
const initials = portSlug
|
||||
? portSlug
|
||||
.split('-')
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
: 'CR';
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -71,15 +60,10 @@ export function MobileTopbar() {
|
||||
{backTarget ? (
|
||||
<BackButton variant="mobile" />
|
||||
) : (
|
||||
<div
|
||||
aria-label={portTitle || 'Home'}
|
||||
className={cn(
|
||||
'size-9 shrink-0 rounded-lg flex items-center justify-center',
|
||||
'bg-[#3a7bc8] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25)]',
|
||||
)}
|
||||
>
|
||||
<span className="text-white font-bold text-[13px] tracking-tight">{initials}</span>
|
||||
</div>
|
||||
// No back affordance on top-level pages. Render an empty spacer the
|
||||
// same width as the right-hand action slot so the centered title
|
||||
// stays optically centered (the brand "PN" mark was removed here).
|
||||
<div className="size-11 shrink-0" aria-hidden />
|
||||
)}
|
||||
|
||||
<h1
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
UsersRound,
|
||||
Bookmark,
|
||||
Anchor,
|
||||
KeyRound,
|
||||
@@ -113,6 +114,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
items: [
|
||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||
{ href: `${base}/client-groups`, label: 'Client Groups', icon: UsersRound },
|
||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ResidentialInterest {
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
preferences: string | null;
|
||||
residenceType: string | null;
|
||||
assignedTo: string | null;
|
||||
client: { id: string; fullName: string } | null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
import { RESIDENCE_TYPES } from '@/lib/validators/residential';
|
||||
|
||||
interface ResidentialInterest {
|
||||
id: string;
|
||||
@@ -16,6 +18,7 @@ interface ResidentialInterest {
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
preferences: string | null;
|
||||
residenceType: string | null;
|
||||
assignedTo: string | null;
|
||||
}
|
||||
|
||||
@@ -27,6 +30,7 @@ interface Args {
|
||||
}
|
||||
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
const RESIDENCE_TYPE_OPTIONS = RESIDENCE_TYPES.map((t) => ({ value: t, label: t }));
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -95,6 +99,8 @@ function OverviewTab({
|
||||
stageOptions: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
const update = useInterestPatch(interestId);
|
||||
// CM-5: residential assignment row hidden when the per-port toggle is off.
|
||||
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
|
||||
const save = (field: string) => async (next: string | null) => {
|
||||
await update.mutateAsync({ [field]: next });
|
||||
};
|
||||
@@ -105,6 +111,7 @@ function OverviewTab({
|
||||
}>({
|
||||
queryKey: ['residential-assignable-users'],
|
||||
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
|
||||
enabled: assignmentEnabled,
|
||||
});
|
||||
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
|
||||
value: u.id,
|
||||
@@ -132,19 +139,30 @@ function OverviewTab({
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Assigned to">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={assigneeOptions}
|
||||
value={interest.assignedTo}
|
||||
onSave={save('assignedTo')}
|
||||
placeholder="Unassigned"
|
||||
/>
|
||||
</Row>
|
||||
{assignmentEnabled ? (
|
||||
<Row label="Assigned to">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={assigneeOptions}
|
||||
value={interest.assignedTo}
|
||||
onSave={save('assignedTo')}
|
||||
placeholder="Unassigned"
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Details</h3>
|
||||
<Row label="Residence type">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={RESIDENCE_TYPE_OPTIONS}
|
||||
value={interest.residenceType}
|
||||
onSave={save('residenceType')}
|
||||
placeholder="Not set"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Preferences">
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
|
||||
@@ -320,9 +320,11 @@ interface ScanShellProps {
|
||||
* imagery. */
|
||||
logoUrl?: string | null;
|
||||
portName?: string | null;
|
||||
/** CM-6: when true, skip ALL parsing - open an empty form for manual entry. */
|
||||
manualEntry?: boolean;
|
||||
}
|
||||
|
||||
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
|
||||
export function ScanShell({ logoUrl, portName, manualEntry = false }: ScanShellProps = {}) {
|
||||
const router = useRouter();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
|
||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
setCurrentFile(file);
|
||||
|
||||
// CM-6: manual-entry mode - the port admin disabled scanning. Skip
|
||||
// Tesseract AND the server call entirely; go straight to an empty form.
|
||||
if (manualEntry) {
|
||||
setState({
|
||||
kind: 'verify',
|
||||
parsed: {
|
||||
establishment: null,
|
||||
date: null,
|
||||
amount: null,
|
||||
currency: null,
|
||||
lineItems: [],
|
||||
confidence: 0,
|
||||
},
|
||||
source: 'manual',
|
||||
reason: 'manual-mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ kind: 'processing', engine: 'tesseract' });
|
||||
|
||||
// Always run Tesseract first - it's free, on-device, and gives us a
|
||||
|
||||
249
src/components/shared/proxy-card.tsx
Normal file
249
src/components/shared/proxy-card.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mail, Phone, UserCheck, UserPlus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
type ProxyEntityType = 'client' | 'interest' | 'yacht';
|
||||
|
||||
interface Proxy {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
relationship: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
const RESOURCE: Record<ProxyEntityType, 'clients' | 'interests' | 'yachts'> = {
|
||||
client: 'clients',
|
||||
interest: 'interests',
|
||||
yacht: 'yachts',
|
||||
};
|
||||
|
||||
/**
|
||||
* CM-9: point-of-contact ("proxy") panel for a client / interest / yacht detail
|
||||
* page. Reads + edits the per-entity proxy via the entity's sub-resource route.
|
||||
*/
|
||||
export function ProxyCard({
|
||||
entityType,
|
||||
entityId,
|
||||
}: {
|
||||
entityType: ProxyEntityType;
|
||||
entityId: string;
|
||||
}) {
|
||||
const { can } = usePermissions();
|
||||
const canManage = can(RESOURCE[entityType], 'edit');
|
||||
const qc = useQueryClient();
|
||||
const base = `/api/v1/${RESOURCE[entityType]}/${entityId}/proxy`;
|
||||
const queryKey = ['proxy', entityType, entityId];
|
||||
|
||||
const { data } = useQuery<{ data: Proxy | null }>({
|
||||
queryKey,
|
||||
queryFn: () => apiFetch(base),
|
||||
});
|
||||
const proxy = data?.data ?? null;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiFetch(base, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Point of contact removed');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<UserCheck className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
Point of contact
|
||||
</h3>
|
||||
{canManage ? (
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setOpen(true)}>
|
||||
{proxy ? (
|
||||
'Edit'
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{proxy ? (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">
|
||||
{proxy.name}
|
||||
{proxy.relationship ? (
|
||||
<span className="ms-2 text-xs font-normal text-muted-foreground">
|
||||
{proxy.relationship}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{proxy.email ? (
|
||||
<a
|
||||
href={`mailto:${proxy.email}`}
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5" aria-hidden />
|
||||
{proxy.email}
|
||||
</a>
|
||||
) : null}
|
||||
{proxy.phone ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Phone className="h-3.5 w-3.5" aria-hidden />
|
||||
{proxy.phone}
|
||||
</p>
|
||||
) : null}
|
||||
{proxy.notes ? <p className="text-xs text-muted-foreground">{proxy.notes}</p> : null}
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate()}
|
||||
disabled={remove.isPending}
|
||||
className="pt-1 text-xs text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No proxy set — comms go to the {entityType} directly.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{open ? (
|
||||
<ProxyDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
base={base}
|
||||
existing={proxy}
|
||||
entityType={entityType}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
base,
|
||||
existing,
|
||||
entityType,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
base: string;
|
||||
existing: Proxy | null;
|
||||
entityType: ProxyEntityType;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(existing?.name ?? '');
|
||||
const [email, setEmail] = useState(existing?.email ?? '');
|
||||
const [phone, setPhone] = useState(existing?.phone ?? '');
|
||||
const [relationship, setRelationship] = useState(existing?.relationship ?? '');
|
||||
const [notes, setNotes] = useState(existing?.notes ?? '');
|
||||
// State seeds from `existing` at mount; the dialog is remounted on each open
|
||||
// (the parent renders it conditionally), so no reseed effect is needed.
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(base, {
|
||||
method: 'PUT',
|
||||
body: { name: name.trim(), email, phone, relationship, notes },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Point of contact saved');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Point of contact</DialogTitle>
|
||||
<DialogDescription>
|
||||
A person who acts as the point of contact for this {entityType}. Used to address
|
||||
outbound comms.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-name">Name</Label>
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-email">Email</Label>
|
||||
<Input
|
||||
id="proxy-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-phone">Phone</Label>
|
||||
<Input id="proxy-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-rel">Relationship (optional)</Label>
|
||||
<Input
|
||||
id="proxy-rel"
|
||||
placeholder="e.g. broker, spouse, assistant, legal"
|
||||
value={relationship}
|
||||
onChange={(e) => setRelationship(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-notes">Notes (optional)</Label>
|
||||
<Input id="proxy-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={!name.trim() || save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -102,9 +102,11 @@ export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Owner subtitle */}
|
||||
{/* Owner subtitle. `flex min-w-0` (not inline-flex) so a long owner
|
||||
name truncates within the card instead of overflowing ~11px on
|
||||
the narrowest mobile widths (R2). */}
|
||||
{yacht.currentOwnerName ? (
|
||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
<p className="mt-0.5 flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
|
||||
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
<span className="truncate">{yacht.currentOwnerName}</span>
|
||||
</p>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialo
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
|
||||
@@ -176,6 +177,10 @@ function OverviewTab({
|
||||
return (
|
||||
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* CM-9: per-vessel point-of-contact (overrides interest + client). */}
|
||||
<div className="md:col-span-2">
|
||||
<ProxyCard entityType="yacht" entityId={yachtId} />
|
||||
</div>
|
||||
{/* Identity */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||
|
||||
@@ -27,8 +27,11 @@ export interface OnboardingStatusPayload {
|
||||
* and the admin checklist summary. Cached for 60s so all three surfaces
|
||||
* share a single fetch on first paint.
|
||||
*
|
||||
* Pass `enabled=false` to skip the network call (e.g. when the current
|
||||
* user isn't a super_admin and the surface won't render anyway).
|
||||
* Defaults to OFF: the endpoint is admin-only (admin.manage_settings), so
|
||||
* callers must opt in with `enabled: true` once they've confirmed the user is
|
||||
* a super_admin. This prevents a transient 403 (e.g. a stale `isSuperAdmin`
|
||||
* during permission hydration) from firing the privileged request for
|
||||
* non-admins.
|
||||
*/
|
||||
export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
|
||||
return useQuery<OnboardingStatusPayload>({
|
||||
@@ -38,7 +41,7 @@ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
|
||||
(r) => r.data,
|
||||
),
|
||||
staleTime: 60_000,
|
||||
enabled: opts.enabled ?? true,
|
||||
enabled: opts.enabled === true,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
56
src/lib/api/proxy-route-handlers.ts
Normal file
56
src/lib/api/proxy-route-handlers.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* CM-9: shared GET/PUT/DELETE handlers for the per-entity proxy sub-resource
|
||||
* (`/api/v1/{clients|interests|yachts}/[id]/proxy`). Each entity's route.ts
|
||||
* binds these with its own permission resource so we reuse existing
|
||||
* clients/interests/yachts gating instead of a new permission.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { clearProxy, getProxy, setProxy } from '@/lib/services/proxies.service';
|
||||
import { setProxySchema, type ProxyEntityType } from '@/lib/validators/proxies';
|
||||
|
||||
export function makeProxyHandlers(entityType: ProxyEntityType) {
|
||||
const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const proxy = await getProxy(ctx.portId, entityType, params.id!);
|
||||
return NextResponse.json({ data: proxy });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
const putHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, setProxySchema);
|
||||
const proxy = await setProxy(ctx.portId, entityType, params.id!, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: proxy });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await clearProxy(ctx.portId, entityType, params.id!, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
return { getHandler, putHandler, deleteHandler };
|
||||
}
|
||||
62
src/lib/auth/account-setup-email.ts
Normal file
62
src/lib/auth/account-setup-email.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
import { consumePendingWelcome } from './pending-welcome';
|
||||
|
||||
interface AuthBranding {
|
||||
appName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
backgroundUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the email body for better-auth's `sendResetPassword` callback,
|
||||
* choosing between two framings of the same set-password link:
|
||||
*
|
||||
* - a unique **welcome** email when the recipient was flagged by the admin
|
||||
* "create user" flow (a brand-new user has nothing to reset), or
|
||||
* - the standard **password-reset** email for genuine self-service resets.
|
||||
*
|
||||
* Pure aside from rendering — no SMTP, no DB — so the welcome-vs-reset routing
|
||||
* is directly unit-testable.
|
||||
*/
|
||||
export async function buildAccountPasswordEmail(opts: {
|
||||
email: string;
|
||||
name?: string | null;
|
||||
url: string;
|
||||
appName: string;
|
||||
authBranding: AuthBranding | null;
|
||||
}): Promise<{ subject: string; html: string; text: string }> {
|
||||
const emailBranding: BrandingShell | null = opts.authBranding
|
||||
? {
|
||||
logoUrl: opts.authBranding.logoUrl ?? null,
|
||||
backgroundUrl: opts.authBranding.backgroundUrl ?? null,
|
||||
primaryColor: null,
|
||||
emailHeaderHtml: null,
|
||||
emailFooterHtml: null,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (consumePendingWelcome(opts.email)) {
|
||||
const { crmWelcomeEmail } = await import('@/lib/email/templates/crm-welcome');
|
||||
return crmWelcomeEmail(
|
||||
{ link: opts.url, recipientName: opts.name ?? undefined, appName: opts.appName },
|
||||
{ branding: emailBranding },
|
||||
);
|
||||
}
|
||||
|
||||
const { renderShell, safeUrl } = await import('@/lib/email/shell');
|
||||
const subject = `Reset your ${opts.appName} password`;
|
||||
const safeName = (opts.name || 'there').replace(/[<>&]/g, '');
|
||||
const body = `
|
||||
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
||||
<p style="margin-bottom:16px;">You requested a password reset for your ${opts.appName} account.</p>
|
||||
<p style="margin-bottom:16px;">
|
||||
<a href="${safeUrl(opts.url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
||||
- the link expires in 1 hour.
|
||||
</p>
|
||||
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
const html = renderShell({ title: subject, body, branding: emailBranding });
|
||||
const text = `Reset your password: ${opts.url}`;
|
||||
return { subject, html, text };
|
||||
}
|
||||
@@ -77,42 +77,28 @@ function buildAuth() {
|
||||
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
||||
// in dev.
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
|
||||
const [{ sendEmail }, { resolveAuthShellBranding }, { buildAccountPasswordEmail }] =
|
||||
await Promise.all([
|
||||
import('@/lib/email'),
|
||||
import('@/lib/email/shell'),
|
||||
import('@/lib/email/auth-shell-branding'),
|
||||
import('@/lib/auth/account-setup-email'),
|
||||
]);
|
||||
|
||||
const branding = await resolveAuthShellBranding();
|
||||
const appName = branding?.appName ?? 'CRM';
|
||||
const subject = `Reset your ${appName} password`;
|
||||
const safeName = (user.name || 'there').replace(/[<>&]/g, '');
|
||||
const body = `
|
||||
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
||||
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
|
||||
<p style="margin-bottom:16px;">
|
||||
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
||||
- the link expires in 1 hour.
|
||||
</p>
|
||||
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
|
||||
const html = renderShell({
|
||||
title: subject,
|
||||
body,
|
||||
branding: branding
|
||||
? {
|
||||
logoUrl: branding.logoUrl,
|
||||
backgroundUrl: branding.backgroundUrl,
|
||||
primaryColor: null,
|
||||
emailHeaderHtml: null,
|
||||
emailFooterHtml: null,
|
||||
}
|
||||
: null,
|
||||
// Admin-created users ride the same reset-token machinery but should
|
||||
// receive a welcome email, not a "you requested a reset" one — the
|
||||
// create-user service marks them just before triggering this. The
|
||||
// builder picks welcome-vs-reset and renders accordingly.
|
||||
const mail = await buildAccountPasswordEmail({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
url,
|
||||
appName,
|
||||
authBranding: branding,
|
||||
});
|
||||
const text = `Reset your password: ${url}`;
|
||||
await sendEmail(user.email, subject, html, undefined, text);
|
||||
await sendEmail(user.email, mail.subject, mail.html, undefined, mail.text);
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
29
src/lib/auth/pending-welcome.ts
Normal file
29
src/lib/auth/pending-welcome.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Bridges the admin "create user" flow to better-auth's single
|
||||
* `sendResetPassword` callback.
|
||||
*
|
||||
* A brand-new admin-created user is provisioned with a throwaway password and
|
||||
* then sent a set-password link via better-auth's password-reset machinery — but
|
||||
* the *email* should read as a welcome, not a "you requested a reset". better-auth
|
||||
* exposes only one `sendResetPassword` callback (no per-call context), so the
|
||||
* create-user service marks the recipient here immediately before triggering the
|
||||
* reset; the callback consumes the mark and renders the welcome email instead.
|
||||
*
|
||||
* Mark and consume happen in the same process within a single synchronous
|
||||
* request flow (`createUser` awaits `requestPasswordReset`, which awaits the
|
||||
* callback), so this module-level set is safe — it is never read across requests.
|
||||
* Keyed by lowercased email; distinct recipients never collide.
|
||||
*/
|
||||
const pendingWelcome = new Set<string>();
|
||||
|
||||
export function markPendingWelcome(email: string): void {
|
||||
pendingWelcome.add(email.toLowerCase());
|
||||
}
|
||||
|
||||
/** Returns true (and clears the mark) when this email was flagged as a welcome. */
|
||||
export function consumePendingWelcome(email: string): boolean {
|
||||
const key = email.toLowerCase();
|
||||
const had = pendingWelcome.has(key);
|
||||
pendingWelcome.delete(key);
|
||||
return had;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export type PermissionAction<R extends PermissionResource> = keyof RolePermissio
|
||||
* (audit finding L23).
|
||||
*/
|
||||
export const PERMISSION_CATALOG = {
|
||||
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'],
|
||||
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'],
|
||||
interests: [
|
||||
'view',
|
||||
'create',
|
||||
@@ -70,6 +70,7 @@ export const PERMISSION_CATALOG = {
|
||||
residential_clients: ['view', 'create', 'edit', 'delete'],
|
||||
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
||||
inquiries: ['view', 'manage'],
|
||||
client_groups: ['view', 'manage'],
|
||||
} as const satisfies {
|
||||
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
||||
};
|
||||
|
||||
@@ -332,13 +332,27 @@ export function formatSource(source: string | null | undefined): string | null {
|
||||
export const ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: 'Super Admin',
|
||||
director: 'Director',
|
||||
sales_manager: 'Sales Manager',
|
||||
// Single sales role for the deployment — `sales_manager` is the full-access
|
||||
// sales map, surfaced to users simply as "Sales". `sales_agent` is retired
|
||||
// from selection (see NON_ASSIGNABLE_ROLE_NAMES) but keeps its label for any
|
||||
// legacy assignment still rendered.
|
||||
sales_manager: 'Sales',
|
||||
sales_agent: 'Sales Agent',
|
||||
finance_manager: 'Finance Manager',
|
||||
viewer: 'Viewer',
|
||||
residential_partner: 'Residential Partner',
|
||||
};
|
||||
|
||||
/**
|
||||
* System roles that must not appear as choices when assigning a role to a
|
||||
* user. `super_admin` is platform-owner-only (minted via the invitation
|
||||
* flow's isSuperAdmin gate, never the role dropdown); `sales_agent` is
|
||||
* superseded by the single "Sales" role. The create/edit user form still
|
||||
* surfaces a user's *current* role even if it's listed here, so existing
|
||||
* assignments stay editable.
|
||||
*/
|
||||
export const NON_ASSIGNABLE_ROLE_NAMES = new Set(['super_admin', 'sales_agent']);
|
||||
|
||||
/** Returns the human label for a stored role name. Falls back to a
|
||||
* Title-Case rendering for legacy / custom roles. */
|
||||
export function formatRole(role: string | null | undefined): string {
|
||||
|
||||
52
src/lib/db/migrations/0094_client_groups.sql
Normal file
52
src/lib/db/migrations/0094_client_groups.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- 0094_client_groups.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- CM-1: first-class client groups (mailing/segment lists) + the membership
|
||||
-- join, plus the new `client_groups` permission resource (view/manage).
|
||||
--
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + a guarded role backfill.
|
||||
-- Safe to re-run.
|
||||
|
||||
-- ─── 1. client_groups (per-port named group) ────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS client_groups (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
color text NOT NULL DEFAULT '#6B7280',
|
||||
mailchimp_tag text,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_client_groups_port ON client_groups(port_id);
|
||||
-- Per-port, case-insensitive name uniqueness among non-archived groups.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_client_groups_port_name
|
||||
ON client_groups(port_id, lower(name))
|
||||
WHERE archived_at IS NULL;
|
||||
|
||||
-- ─── 2. client_group_members (M2M join; carries port_id for tenant isolation) ─
|
||||
CREATE TABLE IF NOT EXISTS client_group_members (
|
||||
group_id text NOT NULL REFERENCES client_groups(id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (group_id, client_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cgm_client ON client_group_members(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cgm_port ON client_group_members(port_id);
|
||||
|
||||
-- ─── 3. `client_groups` permission resource (view/manage) ────────────────────
|
||||
-- New-key only + idempotent via the `? 'client_groups'` guard. Defaults to the
|
||||
-- role's clients access (view ⟵ clients.view, manage ⟵ clients.create) so the
|
||||
-- right roles light up without a manual per-role edit.
|
||||
UPDATE roles
|
||||
SET permissions = permissions || jsonb_build_object(
|
||||
'client_groups', 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 ? 'client_groups');
|
||||
24
src/lib/db/migrations/0095_proxies.sql
Normal file
24
src/lib/db/migrations/0095_proxies.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- 0095_proxies.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- CM-9: per-entity point-of-contact ("proxy") attachable to a client, interest,
|
||||
-- or yacht. At most one per entity; outbound comms resolve the most specific
|
||||
-- via yacht → interest → client. entity_id is polymorphic (no FK; validated in
|
||||
-- the service against the right table). Idempotent — safe to re-run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proxies (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL,
|
||||
entity_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
email text,
|
||||
phone text,
|
||||
relationship text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_proxies_entity ON proxies(port_id, entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_entity ON proxies(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_port ON proxies(port_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- CM-2 Part B: per-interest, per-berth deal-price override.
|
||||
-- Null = use the berth's canonical list price (berths.price). When set, this
|
||||
-- supersedes the list price for THIS interest's generated documents
|
||||
-- (resolved in eoi-context via resolveBerthPriceForInterest).
|
||||
ALTER TABLE interest_berths
|
||||
ADD COLUMN IF NOT EXISTS price_override numeric,
|
||||
ADD COLUMN IF NOT EXISTS price_override_currency text;
|
||||
14
src/lib/db/migrations/0097_director_role_full_sales.sql
Normal file
14
src/lib/db/migrations/0097_director_role_full_sales.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Director becomes a senior-title twin of the single "Sales" role: identical
|
||||
-- capabilities, no admin/settings access (admin stays Super-Admin-only).
|
||||
--
|
||||
-- The bootstrap seed inserts system roles with ON CONFLICT DO NOTHING, so
|
||||
-- editing DIRECTOR_PERMISSIONS in seed-permissions.ts only affects fresh seeds.
|
||||
-- Existing deployments need this data update to bring the stored `director`
|
||||
-- row in line. Idempotent: re-running simply re-copies the sales map.
|
||||
UPDATE roles AS d
|
||||
SET permissions = sm.permissions,
|
||||
description = 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
|
||||
updated_at = now()
|
||||
FROM roles AS sm
|
||||
WHERE d.name = 'director'
|
||||
AND sm.name = 'sales_manager';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- New toggleable permission: clients.gdpr_export (trigger + download a client's
|
||||
-- GDPR data export). Previously the export routes were gated by
|
||||
-- admin.manage_settings, which sales roles lack. This grants it to the
|
||||
-- sales-capable system roles by default and makes it an explicit (off) toggle
|
||||
-- everywhere else, so admins can withhold it per-user (which hides the button).
|
||||
--
|
||||
-- Existing role rows store permissions as jsonb, so editing the seed/role maps
|
||||
-- alone won't reach them — this backfills the key. Idempotent.
|
||||
|
||||
-- Sales-capable system roles get it ON by default.
|
||||
UPDATE roles
|
||||
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'true'::jsonb, true),
|
||||
updated_at = now()
|
||||
WHERE name IN ('super_admin', 'director', 'sales_manager', 'sales_agent')
|
||||
AND permissions ? 'clients';
|
||||
|
||||
-- Every other role that has a clients block but not the key yet defaults to OFF,
|
||||
-- so the permission surfaces as an explicit toggle in the matrix.
|
||||
UPDATE roles
|
||||
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'false'::jsonb, true),
|
||||
updated_at = now()
|
||||
WHERE permissions ? 'clients'
|
||||
AND NOT (permissions -> 'clients' ? 'gdpr_export');
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Residential interests: structured residence unit type the lead is pursuing
|
||||
-- (e.g. "Two Bedroom Marina Villa"). Mirrors the multi-select on the website's
|
||||
-- register-interest form. Nullable; additive — safe to apply online.
|
||||
ALTER TABLE residential_interests
|
||||
ADD COLUMN IF NOT EXISTS residence_type text;
|
||||
73
src/lib/db/schema/client-groups.ts
Normal file
73
src/lib/db/schema/client-groups.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Client groups (CM-1) - first-class mailing/segment groups for clients.
|
||||
*
|
||||
* A `client_groups` row is a named, per-port group (e.g. a mailing list).
|
||||
* `client_group_members` is the M2M join to `clients`. Membership carries its
|
||||
* own `port_id` for defense-in-depth tenant isolation (same doctrine as the
|
||||
* document-folders aggregated projection - port_id at every join).
|
||||
*
|
||||
* Optional Mailchimp mapping lives on the group row: `mailchimpTag` is the
|
||||
* tag/segment name pushed to the port's single Mailchimp audience. Null until
|
||||
* an admin wires Mailchimp up (the integration is inert without creds).
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { clients } from './clients';
|
||||
import { ports } from './ports';
|
||||
|
||||
export const clientGroups = pgTable(
|
||||
'client_groups',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
/** Chip color in the CRM UI. */
|
||||
color: text('color').notNull().default('#6B7280'),
|
||||
/** CM-1 Mailchimp: the tag/segment name this group maps to in the port's
|
||||
* single Mailchimp audience. Null = not synced. */
|
||||
mailchimpTag: text('mailchimp_tag'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_client_groups_port').on(table.portId),
|
||||
// Per-port, case-insensitive name uniqueness among non-archived groups.
|
||||
uniqueIndex('idx_client_groups_port_name')
|
||||
.on(table.portId, sql`lower(${table.name})`)
|
||||
.where(sql`${table.archivedAt} IS NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
export const clientGroupMembers = pgTable(
|
||||
'client_group_members',
|
||||
{
|
||||
groupId: text('group_id')
|
||||
.notNull()
|
||||
.references(() => clientGroups.id, { onDelete: 'cascade' }),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.groupId, table.clientId] }),
|
||||
index('idx_cgm_client').on(table.clientId),
|
||||
index('idx_cgm_port').on(table.portId),
|
||||
],
|
||||
);
|
||||
|
||||
export type ClientGroup = typeof clientGroups.$inferSelect;
|
||||
export type NewClientGroup = typeof clientGroups.$inferInsert;
|
||||
export type ClientGroupMember = typeof clientGroupMembers.$inferSelect;
|
||||
export type NewClientGroupMember = typeof clientGroupMembers.$inferInsert;
|
||||
@@ -7,6 +7,12 @@ export * from './users';
|
||||
// Clients
|
||||
export * from './clients';
|
||||
|
||||
// Client groups (CM-1 - mailing/segment groups)
|
||||
export * from './client-groups';
|
||||
|
||||
// Proxies / points-of-contact (CM-9 - polymorphic across client/interest/yacht)
|
||||
export * from './proxies';
|
||||
|
||||
// Companies
|
||||
export * from './companies';
|
||||
|
||||
|
||||
@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
|
||||
addedBy: text('added_by'),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
notes: text('notes'),
|
||||
// CM-2 Part B: deal-specific price for THIS (interest, berth). Null = use
|
||||
// the berth's canonical list price. Does not touch berths.price.
|
||||
priceOverride: numeric('price_override'),
|
||||
priceOverrideCurrency: text('price_override_currency'),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
||||
|
||||
48
src/lib/db/schema/proxies.ts
Normal file
48
src/lib/db/schema/proxies.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Proxies / points-of-contact (CM-9).
|
||||
*
|
||||
* A `proxy` is a designated contact person who acts on behalf of an entity.
|
||||
* Polymorphic: attachable to a `client` (default), an `interest` (per-deal
|
||||
* override), or a `yacht` (per-vessel override). At most one proxy per entity
|
||||
* (unique index). Outbound comms resolve the most specific proxy via the chain
|
||||
* yacht → interest → client (see resolveEffectiveProxy in proxies.service).
|
||||
*
|
||||
* `entity_id` is polymorphic (no FK) — validated against the right table in the
|
||||
* service, same pattern as polymorphic yacht ownership / notes.
|
||||
*/
|
||||
|
||||
import { index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ports } from './ports';
|
||||
|
||||
export const proxies = pgTable(
|
||||
'proxies',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
/** 'client' | 'interest' | 'yacht' */
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
/** Free-form relationship label, e.g. broker / spouse / assistant / legal. */
|
||||
relationship: text('relationship'),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
// At most one proxy per entity.
|
||||
uniqueIndex('uniq_proxies_entity').on(table.portId, table.entityType, table.entityId),
|
||||
index('idx_proxies_entity').on(table.entityType, table.entityId),
|
||||
index('idx_proxies_port').on(table.portId),
|
||||
],
|
||||
);
|
||||
|
||||
export type Proxy = typeof proxies.$inferSelect;
|
||||
export type NewProxy = typeof proxies.$inferInsert;
|
||||
@@ -97,6 +97,13 @@ export const residentialInterests = pgTable(
|
||||
* heavily. Schema can grow into structured columns later if needed.
|
||||
*/
|
||||
preferences: text('preferences'),
|
||||
/**
|
||||
* Structured residence unit type the lead is pursuing (e.g. "Two Bedroom
|
||||
* Marina Villa"). Mirrors the multi-select on the website's register-interest
|
||||
* form; on a structured interest it captures the single unit type being
|
||||
* worked. Nullable - older rows + manual entries may leave it unset.
|
||||
*/
|
||||
residenceType: text('residence_type'),
|
||||
/**
|
||||
* better-auth user id of the residential team member working this lead.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,9 @@ export type RolePermissions = {
|
||||
delete: boolean;
|
||||
merge: boolean;
|
||||
export: boolean;
|
||||
/** Trigger + download a GDPR data export for a client. Toggleable so it
|
||||
* can be hidden from a user (e.g. a sales rep) when withheld. */
|
||||
gdpr_export: boolean;
|
||||
};
|
||||
interests: {
|
||||
view: boolean;
|
||||
@@ -166,6 +169,10 @@ export type RolePermissions = {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
};
|
||||
client_groups: {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -153,7 +153,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'director',
|
||||
description: 'Operational admin within assigned port(s). Can manage users and settings.',
|
||||
description: 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
|
||||
permissions: DIRECTOR_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
|
||||
@@ -12,7 +12,15 @@
|
||||
import type { RolePermissions } from './schema/users';
|
||||
|
||||
export const ALL_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
merge: true,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -92,93 +100,27 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
send_for_signing: true,
|
||||
upload_signed: true,
|
||||
delete: true,
|
||||
manage_folders: true,
|
||||
},
|
||||
expenses: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
export: true,
|
||||
scan_receipt: true,
|
||||
},
|
||||
invoices: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
send: true,
|
||||
record_payment: true,
|
||||
export: true,
|
||||
},
|
||||
payments: { view: true, record: true, delete: true },
|
||||
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||
email: { view: true, send: true, configure_account: true },
|
||||
reminders: {
|
||||
view_own: true,
|
||||
view_all: true,
|
||||
create: true,
|
||||
edit_own: true,
|
||||
edit_all: true,
|
||||
assign_others: true,
|
||||
},
|
||||
calendar: { connect: true, view_events: true },
|
||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||
document_templates: { view: true, generate: true, manage: true },
|
||||
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
|
||||
companies: { view: true, create: true, edit: true, delete: true },
|
||||
memberships: { view: true, manage: true },
|
||||
tenancies: { view: true, manage: true, cancel: true },
|
||||
admin: {
|
||||
manage_users: true,
|
||||
view_audit_log: true,
|
||||
manage_settings: true,
|
||||
manage_webhooks: true,
|
||||
manage_reports: true,
|
||||
manage_custom_fields: true,
|
||||
manage_forms: true,
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
inquiries: {
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
// DIRECTOR_PERMISSIONS is defined just below SALES_MANAGER_PERMISSIONS — it is a
|
||||
// senior-title twin of the single "Sales" role with identical capabilities and
|
||||
// no admin/settings access (reserved for Super Admin). Kept there so it can
|
||||
// reference the sales map directly.
|
||||
|
||||
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
merge: true,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -258,10 +200,27 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Director is now a senior-title twin of the single "Sales" role: identical
|
||||
// capabilities, no admin/settings access (admin stays Super-Admin-only). It
|
||||
// remains a distinct, selectable role purely so the title can differ.
|
||||
export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS;
|
||||
|
||||
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
||||
clients: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: true,
|
||||
gdpr_export: true,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -341,10 +300,22 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
clients: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: false,
|
||||
gdpr_export: false,
|
||||
},
|
||||
interests: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -430,13 +401,25 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: false,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Residential Partner - for an outside party who handles residential
|
||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
clients: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
merge: false,
|
||||
export: false,
|
||||
gdpr_export: false,
|
||||
},
|
||||
interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
@@ -522,4 +505,8 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
view: false,
|
||||
manage: false,
|
||||
},
|
||||
client_groups: {
|
||||
view: false,
|
||||
manage: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Text, render } from '@react-email/components';
|
||||
import { Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
@@ -17,6 +17,9 @@ export interface ContactFormSalesAlertData {
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
// Mirrors the interest-registration alert (inquiry-sales-notification.tsx):
|
||||
// friendly intro, `**Label:** value` detail lines, inline CRM follow-up link,
|
||||
// and a plain-text part — so contact-form alerts read identically to interest ones.
|
||||
function SalesAlertBody({
|
||||
portName,
|
||||
data,
|
||||
@@ -26,61 +29,42 @@ function SalesAlertBody({
|
||||
data: ContactFormSalesAlertData;
|
||||
accent: string;
|
||||
}) {
|
||||
const labelCell = { color: '#666', width: '140px' } as const;
|
||||
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
||||
const comments = data.comments?.trim() ? data.comments : '(none provided)';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
New contact form submission
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
A new contact-form enquiry has come in for <strong>{portName}</strong>. {data.fullName} got
|
||||
in touch via the website contact page - full details below:
|
||||
</Text>
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
cellPadding={6}
|
||||
cellSpacing={0}
|
||||
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={labelCell}>Name</td>
|
||||
<td>{data.fullName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={labelCell}>Email</td>
|
||||
<td>{data.email}</td>
|
||||
</tr>
|
||||
{data.interestType ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Interest</td>
|
||||
<td>{data.interestType}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.comments ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Comments</td>
|
||||
<td>{data.comments}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.crmDeepLink ? (
|
||||
<div style={{ textAlign: 'center', margin: '24px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.crmDeepLink)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '12px 28px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Open in CRM
|
||||
</Button>
|
||||
</div>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {data.fullName}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Email:</strong> {data.email}
|
||||
</Text>
|
||||
{data.interestType ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Interest:</strong> {data.interestType}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
|
||||
<Text style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
|
||||
<strong>Comments:</strong> {comments}
|
||||
</Text>
|
||||
{data.crmDeepLink ? (
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Open the{' '}
|
||||
<Link
|
||||
href={safeUrl(data.crmDeepLink)}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
>
|
||||
{portName} CRM
|
||||
</Link>{' '}
|
||||
to follow up.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +73,7 @@ export async function contactFormSalesAlert(
|
||||
data: ContactFormSalesAlertData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'our team';
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New contact form submission - ${data.fullName}`;
|
||||
@@ -97,8 +81,27 @@ export async function contactFormSalesAlert(
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
});
|
||||
|
||||
const comments = data.comments?.trim() ? data.comments : '(none provided)';
|
||||
const text = [
|
||||
'Hello,',
|
||||
'',
|
||||
`A new contact-form enquiry has come in for ${portName}. ${data.fullName} got in touch via the website contact page - full details below:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
...(data.interestType ? [`Interest: ${data.interestType}`] : []),
|
||||
`Comments: ${comments}`,
|
||||
'',
|
||||
...(data.crmDeepLink
|
||||
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
|
||||
: []),
|
||||
`- ${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
125
src/lib/email/templates/crm-welcome.tsx
Normal file
125
src/lib/email/templates/crm-welcome.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
brandingPrimaryColor,
|
||||
emailStyle,
|
||||
renderShell,
|
||||
safeUrl,
|
||||
type BrandingShell,
|
||||
} from '@/lib/email/shell';
|
||||
|
||||
interface WelcomeData {
|
||||
/** The set-password link (better-auth reset URL landing on /set-password). */
|
||||
link: string;
|
||||
recipientName?: string;
|
||||
/** Human label of the role the account was created with (e.g. "Sales"). */
|
||||
roleName?: string;
|
||||
/** Full product / app name as branded — e.g. "Port Nimara CRM". Falls back
|
||||
* to "Port Nimara CRM". Used verbatim (no " CRM" is appended). */
|
||||
appName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
subject?: string | null;
|
||||
}
|
||||
|
||||
function WelcomeBody({
|
||||
appName,
|
||||
link,
|
||||
recipientName,
|
||||
roleName,
|
||||
accent,
|
||||
}: {
|
||||
appName: string;
|
||||
link: string;
|
||||
recipientName?: string;
|
||||
roleName?: string;
|
||||
accent: string;
|
||||
}) {
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
|
||||
const roleClause = roleName ? ` with the ${roleName} role` : '';
|
||||
return (
|
||||
<>
|
||||
<Text style={emailStyle.title(accent)}>Welcome to {appName}</Text>
|
||||
<Text style={emailStyle.paragraph}>{greeting}</Text>
|
||||
<Text style={emailStyle.paragraph}>
|
||||
An account has been created for you in {appName}
|
||||
{roleClause}. To get started, set your password using the button below — then sign in with
|
||||
this email address.
|
||||
</Text>
|
||||
<div style={emailStyle.buttonRow}>
|
||||
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
|
||||
Set your password
|
||||
</Button>
|
||||
</div>
|
||||
<Text style={emailStyle.signoff}>
|
||||
See you inside,
|
||||
<br />
|
||||
<strong>The {appName} Team</strong>
|
||||
</Text>
|
||||
<Hr style={emailStyle.divider} />
|
||||
<Text style={emailStyle.finePrint}>
|
||||
For security this link will expire after a short while. If it's no longer valid, ask
|
||||
your administrator to send you a new one.
|
||||
<br />
|
||||
<br />
|
||||
If the button doesn't work, paste this link into your browser:
|
||||
<br />
|
||||
<Link
|
||||
href={safeUrl(link)}
|
||||
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||
>
|
||||
{link}
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome / set-your-password email for an admin-created CRM user. Distinct from
|
||||
* the self-service "Reset your password" email — a brand-new user has nothing to
|
||||
* reset. Wraps the same better-auth reset link that the /set-password page
|
||||
* consumes, but in onboarding framing.
|
||||
*/
|
||||
export async function crmWelcomeEmail(
|
||||
data: WelcomeData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const appName = data.appName ?? 'Port Nimara CRM';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `Welcome to ${appName} — set your password`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
<WelcomeBody
|
||||
appName={appName}
|
||||
link={data.link}
|
||||
recipientName={data.recipientName}
|
||||
roleName={data.roleName}
|
||||
accent={accent}
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
|
||||
const text = [
|
||||
`Welcome to ${appName}`,
|
||||
'',
|
||||
`An account has been created for you${data.roleName ? ` with the ${data.roleName} role` : ''}.`,
|
||||
`Set your password to get started: ${data.link}`,
|
||||
'',
|
||||
`For security this link will expire after a short while — if it's no longer valid, ask your administrator to send a new one.`,
|
||||
'',
|
||||
`See you inside,`,
|
||||
`The ${appName} Team`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Link, Text, render } from '@react-email/components';
|
||||
import { Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
@@ -11,18 +11,35 @@ interface RenderOpts {
|
||||
export interface ResidentialClientConfirmationData {
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
residenceTypes?: string[];
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable list of the residence types a lead selected, e.g.
|
||||
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
|
||||
* Falls back to a generic phrase when nothing was selected so the copy
|
||||
* always reads naturally.
|
||||
*/
|
||||
function residencePhrase(portName: string, types: string[] | undefined): string {
|
||||
const list = (types ?? []).filter(Boolean);
|
||||
if (list.length === 0) return `the residences at ${portName}`;
|
||||
if (list.length === 1) return `the ${list[0]}`;
|
||||
if (list.length === 2) return `the ${list[0]} and the ${list[1]}`;
|
||||
return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`;
|
||||
}
|
||||
|
||||
function ClientConfirmationBody({
|
||||
portName,
|
||||
firstName,
|
||||
contactEmail,
|
||||
residencePhraseText,
|
||||
accent,
|
||||
}: {
|
||||
portName: string;
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
residencePhraseText: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -34,7 +51,7 @@ function ClientConfirmationBody({
|
||||
Dear {firstName},
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Thank you for your interest in the residences at {portName}. Our residential sales team has
|
||||
Thank you for your interest in {residencePhraseText}. Our residential sales team has
|
||||
received your enquiry, and a member of the team will be in touch shortly with the details
|
||||
you've requested.
|
||||
</Text>
|
||||
@@ -66,18 +83,31 @@ export async function residentialClientConfirmation(
|
||||
? overrides.subject
|
||||
: `Thank you for your interest in ${portName} Residences`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const residencePhraseText = residencePhrase(portName, data.residenceTypes);
|
||||
const body = await render(
|
||||
<ClientConfirmationBody
|
||||
portName={portName}
|
||||
firstName={data.firstName}
|
||||
contactEmail={data.contactEmail}
|
||||
residencePhraseText={residencePhraseText}
|
||||
accent={accent}
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
const text = [
|
||||
`Dear ${data.firstName},`,
|
||||
'',
|
||||
`Thank you for your interest in ${residencePhraseText}. Our residential sales team has received your enquiry, and a member of the team will be in touch shortly with the details you've requested.`,
|
||||
'',
|
||||
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${data.contactEmail}.`,
|
||||
'',
|
||||
'With warm regards,',
|
||||
`The ${portName} Residential Team`,
|
||||
].join('\n');
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +115,7 @@ export interface ResidentialSalesAlertData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
residenceTypes?: string[];
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
@@ -93,6 +124,12 @@ export interface ResidentialSalesAlertData {
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
function formatPreferredContact(method: 'email' | 'phone' | undefined): string | undefined {
|
||||
if (method === 'email') return 'Email';
|
||||
if (method === 'phone') return 'Phone call back';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function SalesAlertBody({
|
||||
portName,
|
||||
data,
|
||||
@@ -102,77 +139,65 @@ function SalesAlertBody({
|
||||
data: ResidentialSalesAlertData;
|
||||
accent: string;
|
||||
}) {
|
||||
const labelCell = { color: '#666', width: '140px' } as const;
|
||||
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
||||
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
|
||||
const preferredContact = formatPreferredContact(data.preferredContactMethod);
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
New residential inquiry
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
A new residential enquiry has come in for <strong>{portName}</strong>. {data.fullName} has
|
||||
asked us to be in touch - full details below:
|
||||
</Text>
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
cellPadding={6}
|
||||
cellSpacing={0}
|
||||
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={labelCell}>Name</td>
|
||||
<td>{data.fullName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={labelCell}>Email</td>
|
||||
<td>{data.email}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={labelCell}>Phone</td>
|
||||
<td>{data.phone}</td>
|
||||
</tr>
|
||||
{data.placeOfResidence ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Residence</td>
|
||||
<td>{data.placeOfResidence}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.preferredContactMethod ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Prefers</td>
|
||||
<td>{data.preferredContactMethod}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.preferences ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Preferences</td>
|
||||
<td>{data.preferences}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.notes ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Notes</td>
|
||||
<td>{data.notes}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {data.fullName}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Email:</strong> {data.email}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Telephone:</strong> {data.phone}
|
||||
</Text>
|
||||
{residenceTypes.length > 0 ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Residence type(s):</strong> {residenceTypes.join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
{preferredContact ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Preferred contact:</strong> {preferredContact}
|
||||
</Text>
|
||||
) : null}
|
||||
{data.placeOfResidence ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Place of residence:</strong> {data.placeOfResidence}
|
||||
</Text>
|
||||
) : null}
|
||||
{data.preferences ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Preferences:</strong> {data.preferences}
|
||||
</Text>
|
||||
) : null}
|
||||
{data.notes ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Comments:</strong> {data.notes}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
{data.crmDeepLink ? (
|
||||
<div style={{ textAlign: 'center', margin: '24px 0' }}>
|
||||
<Button
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Open the{' '}
|
||||
<Link
|
||||
href={safeUrl(data.crmDeepLink)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '12px 28px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
>
|
||||
Open in CRM
|
||||
</Button>
|
||||
</div>
|
||||
{portName} CRM
|
||||
</Link>{' '}
|
||||
to follow up.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
|
||||
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -189,8 +214,32 @@ export async function residentialSalesAlert(
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
});
|
||||
|
||||
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
|
||||
const preferredContact = formatPreferredContact(data.preferredContactMethod);
|
||||
const text = [
|
||||
'Hello,',
|
||||
'',
|
||||
`A new residential enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch - full details below:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
`Telephone: ${data.phone}`,
|
||||
...(residenceTypes.length > 0 ? [`Residence type(s): ${residenceTypes.join(', ')}`] : []),
|
||||
...(preferredContact ? [`Preferred contact: ${preferredContact}`] : []),
|
||||
...(data.placeOfResidence ? [`Place of residence: ${data.placeOfResidence}`] : []),
|
||||
...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
|
||||
...(data.notes ? [`Comments: ${data.notes}`] : []),
|
||||
'',
|
||||
...(data.crmDeepLink
|
||||
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
|
||||
: []),
|
||||
`- ${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
152
src/lib/email/templates/signing-status-notification.tsx
Normal file
152
src/lib/email/templates/signing-status-notification.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Internal "signing progress" alert — sent to the port's configured
|
||||
* signing-notification recipients (e.g. the admin + sales@) so staff get
|
||||
* a heads-up every time a party signs and again when a document is fully
|
||||
* signed. This is the CRM equivalent of the legacy "Document Signed" /
|
||||
* "EOI Complete Update Status" Activepieces flows.
|
||||
*
|
||||
* Two events:
|
||||
* - `signed` — a single party just signed (carries who + progress).
|
||||
* - `completed` — all parties have signed.
|
||||
*
|
||||
* Unlike the signer-facing templates, this one links back into the CRM
|
||||
* (deep link to the document) rather than to a signing page — the
|
||||
* recipients are staff, not signers.
|
||||
*/
|
||||
|
||||
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
subject?: string | null;
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export interface StatusNotificationData {
|
||||
event: 'signed' | 'completed';
|
||||
documentLabel: string;
|
||||
/** Deal / client name for the salutation + subject context. */
|
||||
clientName: string;
|
||||
portName: string;
|
||||
/** Deep link into the CRM document detail page. */
|
||||
crmUrl: string;
|
||||
/** For `signed`: who just signed. */
|
||||
signerName?: string | null;
|
||||
signerRole?: string | null;
|
||||
/** For `signed`: progress within the signing order. */
|
||||
signedCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
function roleLabel(role?: string | null): string {
|
||||
switch (role) {
|
||||
case 'client':
|
||||
return 'the client';
|
||||
case 'developer':
|
||||
return 'the developer';
|
||||
case 'approver':
|
||||
return 'the approver';
|
||||
case 'witness':
|
||||
return 'a witness';
|
||||
default:
|
||||
return 'a signer';
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBody({ data, accent }: { data: StatusNotificationData; accent: string }) {
|
||||
const isCompleted = data.event === 'completed';
|
||||
const progress =
|
||||
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
|
||||
? `${data.signedCount} of ${data.totalCount}`
|
||||
: null;
|
||||
|
||||
const heading = isCompleted
|
||||
? `${data.documentLabel} fully signed`
|
||||
: `${data.signerName ?? 'A signer'} has signed`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
{heading}
|
||||
</Text>
|
||||
{isCompleted ? (
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
The {data.documentLabel} for <strong>{data.clientName}</strong> has now been signed by all
|
||||
parties. The fully signed PDF has been filed against the deal in the {data.portName} CRM.
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
<strong>{data.signerName ?? 'A signer'}</strong> ({roleLabel(data.signerRole)}) has signed
|
||||
the {data.documentLabel} for <strong>{data.clientName}</strong>
|
||||
{progress ? ` — ${progress} signatures collected so far.` : '.'}
|
||||
</Text>
|
||||
)}
|
||||
<div style={{ textAlign: 'center', margin: '28px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.crmUrl)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 36px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
View in CRM
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
|
||||
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
|
||||
Open the deal:{' '}
|
||||
<Link
|
||||
href={safeUrl(data.crmUrl)}
|
||||
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||
>
|
||||
{data.crmUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '13px', color: '#999', lineHeight: '1.5', marginTop: '14px' }}>
|
||||
You're receiving this because you're on the signing-notification list for{' '}
|
||||
{data.portName}. An administrator can change who gets these alerts in CRM settings.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function signingStatusNotificationEmail(
|
||||
data: StatusNotificationData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const isCompleted = data.event === 'completed';
|
||||
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{clientName\}\}/g, data.clientName)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: isCompleted
|
||||
? `${data.documentLabel} fully signed — ${data.clientName}`
|
||||
: `${data.signerName ?? 'A signer'} signed the ${data.documentLabel} — ${data.clientName}`;
|
||||
|
||||
const body = await render(<StatusBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
const progress =
|
||||
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
|
||||
? ` (${data.signedCount} of ${data.totalCount} signed)`
|
||||
: '';
|
||||
const text = isCompleted
|
||||
? `The ${data.documentLabel} for ${data.clientName} has been signed by all parties and filed in the ${data.portName} CRM.\n\nView in CRM: ${data.crmUrl}`
|
||||
: `${data.signerName ?? 'A signer'} (${roleLabel(data.signerRole)}) has signed the ${data.documentLabel} for ${data.clientName}${progress}.\n\nView in CRM: ${data.crmUrl}`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -357,10 +357,14 @@ export function extractFromOcrText(rawText: string): {
|
||||
};
|
||||
}
|
||||
|
||||
// Purchase price: "PURCHASE PRICE:\nFEE SIMPLE OR STRATA LOT\n3,880,800 USD"
|
||||
const priceMatch = text.match(/PURCHASE\s+PRICE[\s\S]{0,80}?([0-9][0-9,]+)\s*USD/i);
|
||||
if (priceMatch) {
|
||||
out.price = { value: Number(priceMatch[1]!.replace(/,/g, '')), confidence: 0.7, engine: 'ocr' };
|
||||
// Purchase price: the single clean comma-grouped currency figure. The rates
|
||||
// on the same sheet are letter-spaced (garble) and below the floor, so they
|
||||
// never collide with the main price. See extractPurchasePrice().
|
||||
const priceResult = extractPurchasePrice(text);
|
||||
if (priceResult.value != null) {
|
||||
out.price = { value: priceResult.value, confidence: priceResult.confidence, engine: 'ocr' };
|
||||
} else if (priceResult.warning) {
|
||||
warnings.push(priceResult.warning);
|
||||
}
|
||||
|
||||
// Pricing validity: "ALL PRICES ABOVE ARE CONFIRMED THROUGH UNTIL SEPTEMBER 15TH, 2025"
|
||||
@@ -507,6 +511,62 @@ function coerceFieldValue(key: keyof ExtractedBerthFields, raw: string): string
|
||||
return numeric < 0 ? null : numeric;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor that separates a 6–7-figure purchase price from the ≤~12k weekly/daily
|
||||
* lease rates printed on the same sheet. Observed prices: 277,200 … 5,433,120;
|
||||
* observed weekly highs ≤ 11,341. A wide-margin separator.
|
||||
*/
|
||||
export const PURCHASE_PRICE_FLOOR = 50_000;
|
||||
|
||||
/**
|
||||
* Strict clean comma-grouped currency token. On the real spec sheets the
|
||||
* purchase price is the one figure rendered WITHOUT letter-spacing (the large
|
||||
* bold number); the weekly/daily rates ARE letter-spaced and garble in text
|
||||
* extraction, so they never match this pattern. The floor is a second guard
|
||||
* for clean/synthetic PDFs where rates would also extract cleanly.
|
||||
*/
|
||||
const PRICE_TOKEN_RE = /\b(\d{1,3}(?:,\d{3})+)\s?(USD|EUR|GBP)\b/gi;
|
||||
|
||||
/**
|
||||
* Extract the single main purchase price from raw PDF text. Returns
|
||||
* `value: null` (with a warning) when zero above-floor tokens are found, or
|
||||
* when two or more DISTINCT above-floor values appear (genuinely ambiguous —
|
||||
* flag for human review rather than guess).
|
||||
*/
|
||||
export function extractPurchasePrice(rawText: string): {
|
||||
value: number | null;
|
||||
currency: string | null;
|
||||
confidence: number;
|
||||
warning?: string;
|
||||
} {
|
||||
const candidates: Array<{ value: number; currency: string }> = [];
|
||||
for (const m of rawText.matchAll(PRICE_TOKEN_RE)) {
|
||||
const value = Number(m[1]!.replace(/,/g, ''));
|
||||
if (Number.isFinite(value) && value >= PURCHASE_PRICE_FLOOR) {
|
||||
candidates.push({ value, currency: m[2]!.toUpperCase() });
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
value: null,
|
||||
currency: null,
|
||||
confidence: 0,
|
||||
warning: 'No purchase-price token found (no clean figure ≥ floor).',
|
||||
};
|
||||
}
|
||||
const distinct = [...new Set(candidates.map((c) => c.value))];
|
||||
if (distinct.length > 1) {
|
||||
return {
|
||||
value: null,
|
||||
currency: null,
|
||||
confidence: 0,
|
||||
warning: `Multiple purchase-price candidates (${distinct.join(', ')}) — needs review.`,
|
||||
};
|
||||
}
|
||||
const best = candidates[0]!;
|
||||
return { value: best.value, currency: best.currency, confidence: 0.95 };
|
||||
}
|
||||
|
||||
/** Parse a human date like "September 15 2025" → "2025-09-15". */
|
||||
export function parseHumanDate(raw: string): string | null {
|
||||
const cleaned = raw.replace(/(\d+)(st|nd|rd|th)/i, '$1').trim();
|
||||
|
||||
166
src/lib/services/berth-price-reconcile.service.ts
Normal file
166
src/lib/services/berth-price-reconcile.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Bulk berth price reconciliation (CM-2 Part A).
|
||||
*
|
||||
* Re-parses each berth's CURRENT spec-sheet PDF (stored parseResults are
|
||||
* stale/wrong — the old purchase-price regex matched 0/113 real sheets),
|
||||
* surfaces old→new price diffs for an admin review page, and applies only the
|
||||
* rows a rep explicitly approves. Nothing is written until apply.
|
||||
*/
|
||||
|
||||
import pLimit from 'p-limit';
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths, berthPdfVersions } from '@/lib/db/schema/berths';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
import { parseBerthPdf, extractPurchasePrice } from './berth-pdf-parser';
|
||||
|
||||
export interface PriceReconcileRow {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
currentPrice: number | null;
|
||||
currentCurrency: string;
|
||||
parsedPrice: number | null;
|
||||
parsedCurrency: string | null;
|
||||
versionId: string | null;
|
||||
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<Buffer | string>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* For every active berth in the port, re-parse the current spec-sheet PDF and
|
||||
* report the parsed main price alongside the stored price. Tenant-scoped by
|
||||
* `portId`. Bounded concurrency keeps the S3/filesystem round-trips in check.
|
||||
*/
|
||||
export async function listPriceReconciliation(portId: string): Promise<PriceReconcileRow[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
berthId: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
currentPrice: berths.price,
|
||||
currentCurrency: berths.priceCurrency,
|
||||
versionId: berths.currentPdfVersionId,
|
||||
storageKey: berthPdfVersions.storageKey,
|
||||
})
|
||||
.from(berths)
|
||||
.leftJoin(berthPdfVersions, eq(berthPdfVersions.id, berths.currentPdfVersionId))
|
||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
|
||||
.orderBy(berths.mooringNumber);
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
const limit = pLimit(8);
|
||||
|
||||
return Promise.all(
|
||||
rows.map((r) =>
|
||||
limit(async (): Promise<PriceReconcileRow> => {
|
||||
const currentPrice = r.currentPrice == null ? null : Number(r.currentPrice);
|
||||
const base = {
|
||||
berthId: r.berthId,
|
||||
mooringNumber: r.mooringNumber,
|
||||
area: r.area,
|
||||
currentPrice,
|
||||
currentCurrency: r.currentCurrency,
|
||||
versionId: r.versionId,
|
||||
};
|
||||
if (!r.versionId || !r.storageKey) {
|
||||
return { ...base, parsedPrice: null, parsedCurrency: null, status: 'no_pdf' };
|
||||
}
|
||||
try {
|
||||
const buffer = await streamToBuffer(
|
||||
(await backend.get(r.storageKey)) as AsyncIterable<Buffer | string>,
|
||||
);
|
||||
const parse = await parseBerthPdf(buffer);
|
||||
const price = extractPurchasePrice(parse.rawText ?? '');
|
||||
if (price.value == null) {
|
||||
return {
|
||||
...base,
|
||||
parsedPrice: null,
|
||||
parsedCurrency: null,
|
||||
status: 'needs_review',
|
||||
warning: price.warning,
|
||||
};
|
||||
}
|
||||
const status = currentPrice === price.value ? 'matched' : 'changed';
|
||||
return { ...base, parsedPrice: price.value, parsedCurrency: price.currency, status };
|
||||
} catch (err) {
|
||||
logger.warn({ berthId: r.berthId, err }, 'price-reconcile: parse failed');
|
||||
return {
|
||||
...base,
|
||||
parsedPrice: null,
|
||||
parsedCurrency: null,
|
||||
status: 'needs_review',
|
||||
warning: 'PDF could not be parsed.',
|
||||
};
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a rep-approved slice of parsed prices to `berths.price`/`priceCurrency`.
|
||||
* Tenant-scoped: cross-port berth ids are silently skipped (defense in depth on
|
||||
* top of the route's permission gate). Stamps each berth's current PDF version
|
||||
* `parseResults.bulkPriceApplied` for audit.
|
||||
*/
|
||||
export async function applyBulkBerthPrices(
|
||||
portId: string,
|
||||
approvals: Array<{ berthId: string; price: number; currency: string }>,
|
||||
actingUserId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
if (approvals.length === 0) return { updated: 0 };
|
||||
const ids = approvals.map((a) => a.berthId);
|
||||
const owned = await db
|
||||
.select({ id: berths.id, vid: berths.currentPdfVersionId })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), inArray(berths.id, ids)));
|
||||
const ownedVid = new Map(owned.map((b) => [b.id, b.vid]));
|
||||
|
||||
let updated = 0;
|
||||
await db.transaction(async (tx) => {
|
||||
for (const a of approvals) {
|
||||
if (!ownedVid.has(a.berthId)) continue; // cross-port → skip
|
||||
if (!Number.isFinite(a.price) || a.price < 0) continue;
|
||||
await tx
|
||||
.update(berths)
|
||||
.set({ price: String(a.price), priceCurrency: a.currency, updatedAt: new Date() })
|
||||
.where(and(eq(berths.id, a.berthId), eq(berths.portId, portId)));
|
||||
const vid = ownedVid.get(a.berthId);
|
||||
if (vid) {
|
||||
const [ver] = await tx
|
||||
.select({ pr: berthPdfVersions.parseResults })
|
||||
.from(berthPdfVersions)
|
||||
.where(eq(berthPdfVersions.id, vid));
|
||||
const prior = (ver?.pr as Record<string, unknown> | null) ?? {};
|
||||
await tx
|
||||
.update(berthPdfVersions)
|
||||
.set({
|
||||
parseResults: {
|
||||
...prior,
|
||||
bulkPriceApplied: {
|
||||
price: a.price,
|
||||
currency: a.currency,
|
||||
by: actingUserId,
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
.where(eq(berthPdfVersions.id, vid));
|
||||
}
|
||||
updated += 1;
|
||||
}
|
||||
});
|
||||
return { updated };
|
||||
}
|
||||
205
src/lib/services/client-groups.service.ts
Normal file
205
src/lib/services/client-groups.service.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* CM-1: client groups (mailing/segment lists) service.
|
||||
*
|
||||
* CRUD for `client_groups` + membership management on `client_group_members`,
|
||||
* plus a member viewer that resolves each client's primary email for the
|
||||
* copy-emails feature. All reads/writes are port-scoped. Membership replace is
|
||||
* a wipe-and-rewrite transaction (same shape as setEntityTags).
|
||||
*/
|
||||
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
|
||||
import { db } from '@/lib/db';
|
||||
import { clientGroupMembers, clientGroups, clients } from '@/lib/db/schema';
|
||||
import { withTransaction } from '@/lib/db/utils';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { syncGroupToMailchimp } from '@/lib/services/mailchimp.service';
|
||||
import type {
|
||||
CreateClientGroupInput,
|
||||
UpdateClientGroupInput,
|
||||
} from '@/lib/validators/client-groups';
|
||||
|
||||
export interface ClientGroupWithCount {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
mailchimpTag: string | null;
|
||||
memberCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
clientId: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
async function assertGroup(id: string, portId: string) {
|
||||
const group = await db.query.clientGroups.findFirst({
|
||||
where: and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)),
|
||||
});
|
||||
if (!group || group.archivedAt) throw new NotFoundError('Client group not found');
|
||||
return group;
|
||||
}
|
||||
|
||||
export async function listClientGroups(portId: string): Promise<ClientGroupWithCount[]> {
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(clientGroups)
|
||||
.where(and(eq(clientGroups.portId, portId), sql`${clientGroups.archivedAt} IS NULL`))
|
||||
.orderBy(desc(clientGroups.createdAt));
|
||||
|
||||
// Member counts in one grouped query (port-scoped).
|
||||
const counts = await db
|
||||
.select({ groupId: clientGroupMembers.groupId, n: sql<number>`count(*)::int` })
|
||||
.from(clientGroupMembers)
|
||||
.where(eq(clientGroupMembers.portId, portId))
|
||||
.groupBy(clientGroupMembers.groupId);
|
||||
const countMap = new Map(counts.map((c) => [c.groupId, c.n]));
|
||||
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
color: g.color,
|
||||
mailchimpTag: g.mailchimpTag,
|
||||
memberCount: countMap.get(g.id) ?? 0,
|
||||
createdAt: g.createdAt,
|
||||
updatedAt: g.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getClientGroupById(id: string, portId: string) {
|
||||
return assertGroup(id, portId);
|
||||
}
|
||||
|
||||
export async function createClientGroup(
|
||||
portId: string,
|
||||
data: CreateClientGroupInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const [group] = await db
|
||||
.insert(clientGroups)
|
||||
.values({
|
||||
portId,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
color: data.color ?? '#6B7280',
|
||||
mailchimpTag: data.mailchimpTag ?? null,
|
||||
})
|
||||
.returning();
|
||||
if (!group) throw new ValidationError('Failed to create client group');
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'create',
|
||||
entityType: 'client_group',
|
||||
entityId: group.id,
|
||||
newValue: toAuditJson(group),
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
export async function updateClientGroup(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdateClientGroupInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
await assertGroup(id, portId);
|
||||
const [updated] = await db
|
||||
.update(clientGroups)
|
||||
.set({
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.color !== undefined ? { color: data.color } : {}),
|
||||
...(data.mailchimpTag !== undefined ? { mailchimpTag: data.mailchimpTag } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Client group not found');
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'update',
|
||||
entityType: 'client_group',
|
||||
entityId: id,
|
||||
newValue: toAuditJson(data),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function archiveClientGroup(id: string, portId: string, meta: AuditMeta) {
|
||||
await assertGroup(id, portId);
|
||||
await db
|
||||
.update(clientGroups)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)));
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'archive',
|
||||
entityType: 'client_group',
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
|
||||
/** Members of a group, each with their primary email (for copy-emails). */
|
||||
export async function listGroupMembers(groupId: string, portId: string): Promise<GroupMember[]> {
|
||||
await assertGroup(groupId, portId);
|
||||
const rows = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
fullName: clients.fullName,
|
||||
email: sql<string | null>`(
|
||||
SELECT cc.value FROM client_contacts cc
|
||||
WHERE cc.client_id = ${clients.id} AND cc.channel = 'email'
|
||||
ORDER BY cc.is_primary DESC
|
||||
LIMIT 1
|
||||
)`,
|
||||
})
|
||||
.from(clientGroupMembers)
|
||||
.innerJoin(clients, eq(clientGroupMembers.clientId, clients.id))
|
||||
.where(and(eq(clientGroupMembers.groupId, groupId), eq(clientGroupMembers.portId, portId)))
|
||||
.orderBy(clients.fullName);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Replace a group's membership with exactly `clientIds` (wipe-and-rewrite). */
|
||||
export async function setGroupMembers(
|
||||
groupId: string,
|
||||
portId: string,
|
||||
clientIds: string[],
|
||||
meta: AuditMeta,
|
||||
): Promise<void> {
|
||||
await assertGroup(groupId, portId);
|
||||
const unique = Array.from(new Set(clientIds));
|
||||
// Tenant-scope guard: every client must belong to this port.
|
||||
if (unique.length > 0) {
|
||||
const valid = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(and(inArray(clients.id, unique), eq(clients.portId, portId)));
|
||||
if (valid.length !== unique.length) {
|
||||
throw new ValidationError('One or more clients are not in this port');
|
||||
}
|
||||
}
|
||||
await withTransaction(async (tx) => {
|
||||
await tx.delete(clientGroupMembers).where(eq(clientGroupMembers.groupId, groupId));
|
||||
if (unique.length > 0) {
|
||||
await tx
|
||||
.insert(clientGroupMembers)
|
||||
.values(unique.map((clientId) => ({ groupId, clientId, portId })));
|
||||
}
|
||||
});
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'update',
|
||||
entityType: 'client_group_members',
|
||||
entityId: groupId,
|
||||
newValue: toAuditJson({ clientIds: unique }),
|
||||
});
|
||||
// CM-1 Mailchimp: fire-and-forget one-way push (inert until configured).
|
||||
void syncGroupToMailchimp(groupId, portId).catch(() => {});
|
||||
}
|
||||
@@ -243,16 +243,19 @@ export interface DocumensoDocument {
|
||||
/**
|
||||
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
|
||||
* email so Documenso doesn't accidentally email real clients during a
|
||||
* data import / migration dry-run. Names are prefixed with the original
|
||||
* email so the recipient (you) can tell who would have received the doc.
|
||||
* data import / migration dry-run.
|
||||
*
|
||||
* In production this env var is unset and recipients flow through unchanged.
|
||||
* The NAME is left untouched: a "Name" signature field auto-fills from the
|
||||
* recipient name and renders into the signed PDF, so any annotation here
|
||||
* (we used to append "(was: <email>)") leaks into the document and overlaps
|
||||
* the signature. The original email is captured in the createDocument log
|
||||
* line instead. In production this env var is unset and recipients flow
|
||||
* through unchanged.
|
||||
*/
|
||||
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
|
||||
if (!env.EMAIL_REDIRECT_TO) return recipients;
|
||||
return recipients.map((r) => ({
|
||||
...r,
|
||||
name: `${r.name} (was: ${r.email})`,
|
||||
email: env.EMAIL_REDIRECT_TO!,
|
||||
}));
|
||||
}
|
||||
@@ -265,11 +268,11 @@ function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoReci
|
||||
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!env.EMAIL_REDIRECT_TO) return payload;
|
||||
const out: Record<string, unknown> = { ...payload };
|
||||
// 2.x recipient shape
|
||||
// 2.x recipient shape — redirect the email only, keep the name clean (it
|
||||
// renders into the signed PDF's Name field). See applyRecipientRedirect.
|
||||
if (Array.isArray(out.recipients)) {
|
||||
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
|
||||
...r,
|
||||
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
|
||||
email: env.EMAIL_REDIRECT_TO,
|
||||
}));
|
||||
}
|
||||
@@ -288,11 +291,41 @@ function applyPayloadRedirect(payload: Record<string, unknown>): Record<string,
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Documenso fires its OWN lifecycle emails for every envelope: each event
|
||||
* below defaults to `true` (verified against the v2.13 OpenAPI + the EOI
|
||||
* Documenso template's stored meta). The CRM is the SOLE sender of signing
|
||||
* comms — branded invitations via `sendSigningInvitation`, plus the
|
||||
* completion / "who signed" alert emails — so we disable ALL of Documenso's
|
||||
* events at creation time.
|
||||
*
|
||||
* Without this, the local-fill pathway (which creates fresh envelopes via
|
||||
* `createDocument`, unlike the template pathway that inherits the template's
|
||||
* all-false `emailSettings`) leaks unbranded "Waiting for others" /
|
||||
* "Signing Complete!" emails — sent with the signed PDF attached from the
|
||||
* Documenso instance's own account (reply-to sales@) — duplicating ours.
|
||||
*
|
||||
* The v2 schema marks every key `required` when the object is present, so
|
||||
* all nine are listed explicitly.
|
||||
*/
|
||||
export const DOCUMENSO_SILENT_EMAIL_SETTINGS = {
|
||||
recipientSigningRequest: false,
|
||||
recipientRemoved: false,
|
||||
recipientSigned: false,
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerDocumentCompleted: false,
|
||||
ownerRecipientExpired: false,
|
||||
ownerDocumentCreated: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Optional metadata applied to the document on creation. v1 accepts
|
||||
* `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
|
||||
* v2's `/envelope/create` accepts the same plus `signingOrder` for
|
||||
* PARALLEL-vs-SEQUENTIAL signing enforcement.
|
||||
* PARALLEL-vs-SEQUENTIAL signing enforcement. `emailSettings` is always
|
||||
* forced to `DOCUMENSO_SILENT_EMAIL_SETTINGS` inside `createDocument`.
|
||||
*/
|
||||
export interface CreateDocumentMeta {
|
||||
subject?: string;
|
||||
@@ -309,7 +342,14 @@ export async function createDocument(
|
||||
portId?: string,
|
||||
meta?: CreateDocumentMeta,
|
||||
): Promise<DocumensoDocument> {
|
||||
const safeRecipients = applyRecipientRedirect(recipients);
|
||||
// Documenso's API requires UPPERCASE recipient roles
|
||||
// (CC | SIGNER | VIEWER | APPROVER | ASSISTANT). The CRM uses lowercase
|
||||
// role strings internally ('signer' / 'approver'), so normalize here at the
|
||||
// API boundary — otherwise create fails with a 400 "Invalid enum value".
|
||||
const safeRecipients = applyRecipientRedirect(recipients).map((r) => ({
|
||||
...r,
|
||||
role: typeof r.role === 'string' ? r.role.toUpperCase() : r.role,
|
||||
}));
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.info(
|
||||
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
|
||||
@@ -335,16 +375,14 @@ export async function createDocument(
|
||||
role: r.role,
|
||||
signingOrder: r.signingOrder || i + 1,
|
||||
})),
|
||||
...(meta
|
||||
? {
|
||||
meta: {
|
||||
...(meta.subject ? { subject: meta.subject } : {}),
|
||||
...(meta.message ? { message: meta.message } : {}),
|
||||
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
|
||||
...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
meta: {
|
||||
// CRM is the sole email sender — Documenso stays silent.
|
||||
emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
|
||||
...(meta?.subject ? { subject: meta.subject } : {}),
|
||||
...(meta?.message ? { message: meta.message } : {}),
|
||||
...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
|
||||
...(meta?.signingOrder ? { signingOrder: meta.signingOrder } : {}),
|
||||
},
|
||||
};
|
||||
form.append('payload', JSON.stringify(payload));
|
||||
form.append(
|
||||
@@ -391,8 +429,13 @@ export async function createDocument(
|
||||
return getDocument(envelopeId, portId);
|
||||
}
|
||||
|
||||
// v1: existing path. Meta keys are accepted at the top level.
|
||||
return documensoFetch(
|
||||
// v1: existing path. Meta keys are accepted at the top level. We still send
|
||||
// `document` (base64) for older Documenso servers that store it inline, but
|
||||
// Documenso 2.x's v1-compat endpoint instead returns a presigned `uploadUrl`
|
||||
// and expects the PDF bytes to be PUT there (the base64 is ignored). So when
|
||||
// the create response carries an `uploadUrl`, upload the bytes to it — without
|
||||
// this the document is created with NO content (signers see a blank PDF).
|
||||
const raw = (await documensoFetch(
|
||||
'/api/v1/documents',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -400,19 +443,49 @@ export async function createDocument(
|
||||
title,
|
||||
document: pdfBase64,
|
||||
recipients: safeRecipients,
|
||||
...(meta?.subject || meta?.message || meta?.redirectUrl
|
||||
? {
|
||||
meta: {
|
||||
...(meta.subject ? { subject: meta.subject } : {}),
|
||||
...(meta.message ? { message: meta.message } : {}),
|
||||
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
meta: {
|
||||
// CRM is the sole email sender — Documenso stays silent.
|
||||
emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
|
||||
...(meta?.subject ? { subject: meta.subject } : {}),
|
||||
...(meta?.message ? { message: meta.message } : {}),
|
||||
...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
portId,
|
||||
).then(normalizeDocument);
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
const uploadUrl = typeof raw.uploadUrl === 'string' ? raw.uploadUrl : null;
|
||||
if (uploadUrl) {
|
||||
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
|
||||
let putRes: Response;
|
||||
try {
|
||||
putRes = await fetchWithTimeout(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/pdf' },
|
||||
body: pdfBuffer,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FetchTimeoutError) {
|
||||
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
||||
internalMessage: `v1 createDocument uploadUrl PUT timed out after ${err.timeoutMs}ms`,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!putRes.ok) {
|
||||
const errText = await putRes.text().catch(() => '');
|
||||
logger.error(
|
||||
{ status: putRes.status, err: errText, portId },
|
||||
'Documenso v1 createDocument uploadUrl PUT failed - document has no content',
|
||||
);
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `v1 createDocument uploadUrl PUT → ${putRes.status}: ${errText}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeDocument(raw);
|
||||
}
|
||||
|
||||
export async function generateDocumentFromTemplate(
|
||||
@@ -1060,8 +1133,52 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
// Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }`
|
||||
// (a presigned S3 URL), NOT the raw PDF. Older v1 returned the PDF bytes
|
||||
// directly. Detect by magic bytes: a real PDF starts with `%PDF-`. When it
|
||||
// doesn't, parse the JSON and follow `downloadUrl` to fetch the actual file.
|
||||
// Saving the JSON body as the "signed PDF" produced a ~500-byte corrupt file
|
||||
// that got emailed to every signer + filed in the CRM (audit 2026-06-24).
|
||||
const firstBuf = Buffer.from(await res.arrayBuffer());
|
||||
if (firstBuf.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||||
return firstBuf;
|
||||
}
|
||||
|
||||
let downloadUrl: string | undefined;
|
||||
try {
|
||||
downloadUrl = (JSON.parse(firstBuf.toString('utf8')) as { downloadUrl?: string }).downloadUrl;
|
||||
} catch {
|
||||
/* body was neither a PDF nor JSON */
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `${path} returned a ${firstBuf.length}-byte non-PDF body with no downloadUrl`,
|
||||
});
|
||||
}
|
||||
|
||||
let pdfRes: Response;
|
||||
try {
|
||||
pdfRes = await fetchWithTimeout(downloadUrl, {});
|
||||
} catch (err) {
|
||||
if (err instanceof FetchTimeoutError) {
|
||||
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
||||
internalMessage: `signed-PDF presigned download timed out after ${err.timeoutMs}ms`,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!pdfRes.ok) {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `signed-PDF presigned URL → ${pdfRes.status}`,
|
||||
});
|
||||
}
|
||||
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
|
||||
if (pdfBuf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `signed-PDF presigned URL returned a ${pdfBuf.length}-byte non-PDF`,
|
||||
});
|
||||
}
|
||||
return pdfBuf;
|
||||
}
|
||||
|
||||
/** Convenience health-check used by the admin "Test connection" button.
|
||||
@@ -1203,8 +1320,6 @@ export interface DocumensoPageDimensions {
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt
|
||||
|
||||
const pageDimensionCache = new Map<string, DocumensoPageDimensions>();
|
||||
|
||||
/** Test seam - clears the page-dimension memoization. */
|
||||
@@ -1212,18 +1327,6 @@ export function __resetDocumensoCachesForTests(): void {
|
||||
pageDimensionCache.clear();
|
||||
}
|
||||
|
||||
async function getPageDimensions(docId: string, portId?: string): Promise<DocumensoPageDimensions> {
|
||||
const cached = pageDimensionCache.get(docId);
|
||||
if (cached) return cached;
|
||||
// v1 doesn't expose page dimensions cleanly via the public API; the auto-
|
||||
// placement use case is footer-anchored signature fields, where a default A4
|
||||
// page rendered by Documenso is a safe assumption. Real page dims can be
|
||||
// wired in a follow-up by parsing the document/document-data endpoints.
|
||||
void portId;
|
||||
pageDimensionCache.set(docId, DEFAULT_PAGE_DIMENSIONS);
|
||||
return DEFAULT_PAGE_DIMENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place one or more fields on a Documenso document. Coordinates are PERCENT
|
||||
* (0-100) and converted to pixels for v1 internally.
|
||||
@@ -1286,16 +1389,23 @@ export async function placeFields(
|
||||
return;
|
||||
}
|
||||
|
||||
const dims = await getPageDimensions(docId, portId);
|
||||
for (const f of fields) {
|
||||
const body = {
|
||||
recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId,
|
||||
type: f.type,
|
||||
pageNumber: f.pageNumber,
|
||||
pageX: Math.round((f.pageX / 100) * dims.width),
|
||||
pageY: Math.round((f.pageY / 100) * dims.height),
|
||||
pageWidth: Math.round((f.pageWidth / 100) * dims.width),
|
||||
pageHeight: Math.round((f.pageHeight / 100) * dims.height),
|
||||
// Documenso 2.x's v1-compat /fields endpoint expects PERCENT coords
|
||||
// (0-100), the same as v2 — NOT absolute points. (Confirmed live:
|
||||
// absolute values like 237 were read as 237% and placed fields far
|
||||
// off-page.) Send the percent values straight through.
|
||||
pageX: f.pageX,
|
||||
pageY: f.pageY,
|
||||
pageWidth: f.pageWidth,
|
||||
pageHeight: f.pageHeight,
|
||||
// Pass fieldMeta through on v1 too (Documenso 2.x's v1-compat endpoint
|
||||
// accepts it) so TEXT fields like "Place of Signing" keep their label /
|
||||
// required / placeholder. Older v1 servers ignore unknown keys.
|
||||
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
||||
};
|
||||
// Retry transient failures so one flaky 5xx mid-loop doesn't leave
|
||||
// the document with a partial field set. 3 attempts at 250 / 500 /
|
||||
@@ -1381,6 +1491,93 @@ export function computeDefaultSignatureLayout(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* EOI page-3 signature-block layout — the six fields template 8 carries, so
|
||||
* the in-app pathway (local pdf-lib fill + flatten → upload as a Documenso
|
||||
* document) produces a signed EOI that matches the legacy template output
|
||||
* exactly. Coordinates are percent of page, captured verbatim from template 8.
|
||||
*
|
||||
* Client (signer 1) gets Signature + Name + Place-of-Signing (TEXT) + Date.
|
||||
* Developer (signer 2) gets Name + Signature. The approver (signer 3) carries
|
||||
* no fields. `fieldMeta` is passed through to Documenso (v1 + v2) so the
|
||||
* Place-of-Signing field keeps its label / required / placeholder.
|
||||
*/
|
||||
export function computeEoiSignatureLayout(
|
||||
clientRecipientId: number | string,
|
||||
developerRecipientId: number | string,
|
||||
): DocumensoFieldPlacement[] {
|
||||
return [
|
||||
{
|
||||
recipientId: clientRecipientId,
|
||||
type: 'SIGNATURE',
|
||||
pageNumber: 3,
|
||||
pageX: 39.64497370960451,
|
||||
pageY: 64.81957098456644,
|
||||
pageWidth: 21.21662173851308,
|
||||
pageHeight: 4.303685358613111,
|
||||
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
|
||||
},
|
||||
{
|
||||
recipientId: clientRecipientId,
|
||||
type: 'NAME',
|
||||
pageNumber: 3,
|
||||
pageX: 14.34911393977768,
|
||||
pageY: 64.81957098456644,
|
||||
pageWidth: 24.33234194973456,
|
||||
pageHeight: 4.303685358613111,
|
||||
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
|
||||
},
|
||||
{
|
||||
recipientId: clientRecipientId,
|
||||
type: 'TEXT',
|
||||
pageNumber: 3,
|
||||
pageX: 14.49704042881816,
|
||||
pageY: 57.4932908677896,
|
||||
pageWidth: 24.4807121661721,
|
||||
pageHeight: 4.40865329418904,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
label: 'Place of Signing',
|
||||
readOnly: false,
|
||||
required: true,
|
||||
textAlign: 'left',
|
||||
placeholder: 'Anguilla, AI',
|
||||
characterLimit: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipientId: clientRecipientId,
|
||||
type: 'DATE',
|
||||
pageNumber: 3,
|
||||
pageX: 39.79290246256028,
|
||||
pageY: 57.4932908677896,
|
||||
pageWidth: 21.06824925816024,
|
||||
pageHeight: 4.40865329418904,
|
||||
fieldMeta: { type: 'date', fontSize: 10, overflow: 'auto', textAlign: 'left' },
|
||||
},
|
||||
{
|
||||
recipientId: developerRecipientId,
|
||||
type: 'NAME',
|
||||
pageNumber: 3,
|
||||
pageX: 14.34911393977768,
|
||||
pageY: 72.56877244919716,
|
||||
pageWidth: 24.33234194973456,
|
||||
pageHeight: 3.988781551885322,
|
||||
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
|
||||
},
|
||||
{
|
||||
recipientId: developerRecipientId,
|
||||
type: 'SIGNATURE',
|
||||
pageNumber: 3,
|
||||
pageX: 39.64497370960451,
|
||||
pageY: 72.56877244919716,
|
||||
pageWidth: 21.21662173851308,
|
||||
pageHeight: 3.988781551885322,
|
||||
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Void/cancel a Documenso document.
|
||||
*
|
||||
|
||||
@@ -38,8 +38,10 @@ import {
|
||||
signingInvitationEmail,
|
||||
signingReminderEmail,
|
||||
} from '@/lib/email/templates/document-signing';
|
||||
import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
|
||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
import { extractSigningToken } from '@/lib/services/documenso-signers';
|
||||
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
@@ -129,9 +131,14 @@ export interface SigningCompletedArgs {
|
||||
* Risk #5 - fixing this mapping prevents an `approver` invite from
|
||||
* landing on `/sign/error`.
|
||||
*/
|
||||
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer' | 'witness'> = {
|
||||
const ROLE_TO_URL_SEGMENT: Record<string, 'client' | 'cc' | 'developer' | 'witness'> = {
|
||||
client: 'client',
|
||||
developer: 'developer',
|
||||
// `document_signers.signer_role` persists Documenso's normalized role, so
|
||||
// the order-2 EOI developer arrives here as 'signer' (not 'developer').
|
||||
// Without this alias the lookup returned `undefined` and the branded link
|
||||
// became `…/sign/undefined/<token>` (dead). Map it to the developer page.
|
||||
signer: 'developer',
|
||||
approver: 'cc',
|
||||
witness: 'witness',
|
||||
other: 'cc',
|
||||
@@ -153,7 +160,9 @@ export function transformSigningUrl(
|
||||
// Trim trailing slashes off the host so we always produce a clean
|
||||
// single `/` between segments.
|
||||
const host = embeddedSigningHost.replace(/\/+$/, '');
|
||||
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
|
||||
// Fall back to the passive `cc` page for any unrecognised role rather than
|
||||
// ever emitting `…/sign/undefined/<token>`.
|
||||
const urlRole = ROLE_TO_URL_SEGMENT[signerRole] ?? 'cc';
|
||||
return `${host}/sign/${urlRole}/${token}`;
|
||||
}
|
||||
|
||||
@@ -329,3 +338,78 @@ export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Internal status notifications (staff "who signed" alerts) ────────────────
|
||||
|
||||
export interface SigningStatusNotificationArgs {
|
||||
portId: string;
|
||||
portName: string;
|
||||
/** `signed` = one party just signed; `completed` = all parties done. */
|
||||
event: 'signed' | 'completed';
|
||||
documentLabel: string;
|
||||
/** Deal / client name for context in the subject + body. */
|
||||
clientName: string;
|
||||
/** Deep link into the CRM document detail page. */
|
||||
crmUrl: string;
|
||||
/** For `signed`: who just signed + their role + running progress. */
|
||||
signerName?: string | null;
|
||||
signerRole?: SignerRole | null;
|
||||
signedCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the port's configured signing-notification recipients (the admin
|
||||
* + sales@, plus any extras) that a party signed or that a document is
|
||||
* fully signed. CRM equivalent of the legacy "Document Signed" /
|
||||
* "EOI Complete Update Status" Activepieces flows.
|
||||
*
|
||||
* Recipients come from the `signing_notification_recipients` setting
|
||||
* (users / roles / emails), falling back to the port's reply-to address
|
||||
* (`email_reply_to`) so the alert is never silently dropped. No-op when
|
||||
* nothing resolves. Per-recipient send so the internal list isn't exposed
|
||||
* across recipients; failures are logged, never thrown (the webhook /
|
||||
* completion path must not be undone by an email hiccup).
|
||||
*/
|
||||
export async function sendSigningStatusNotification(
|
||||
args: SigningStatusNotificationArgs,
|
||||
): Promise<void> {
|
||||
const recipients = await resolveNotificationRecipients(
|
||||
args.portId,
|
||||
'signing_notification_recipients',
|
||||
'email_reply_to',
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const branding = await getBrandingShell(args.portId);
|
||||
const { subject, html, text } = await signingStatusNotificationEmail(
|
||||
{
|
||||
event: args.event,
|
||||
documentLabel: args.documentLabel,
|
||||
clientName: args.clientName,
|
||||
portName: args.portName,
|
||||
crmUrl: args.crmUrl,
|
||||
signerName: args.signerName ?? null,
|
||||
signerRole: args.signerRole ?? null,
|
||||
signedCount: args.signedCount,
|
||||
totalCount: args.totalCount,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
|
||||
const sendLimit = pLimit(3);
|
||||
await Promise.all(
|
||||
recipients.map((to) =>
|
||||
sendLimit(async () => {
|
||||
try {
|
||||
await sendEmail(to, subject, html, undefined, text, args.portId);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, portId: args.portId, recipient: to, event: args.event },
|
||||
'Signing status notification send failed',
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ import { emitToRoom } from '@/lib/socket/server';
|
||||
import { buildStoragePath } from '@/lib/minio';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import {
|
||||
createDocument as documensoCreate,
|
||||
sendDocument as documensoSend,
|
||||
generateDocumentFromTemplate as documensoGenerateFromTemplate,
|
||||
placeFields as documensoPlaceFields,
|
||||
computeEoiSignatureLayout,
|
||||
} from '@/lib/services/documenso-client';
|
||||
import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload';
|
||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
@@ -714,7 +717,15 @@ async function generateAndSignViaInApp(
|
||||
}
|
||||
const pdfBase64 = Buffer.concat(chunks).toString('base64');
|
||||
|
||||
// Create Documenso document
|
||||
// Per-port Documenso config for the post-signing redirect + signing order
|
||||
// (parity with the documenso-template pathway).
|
||||
const docCfg = await getPortDocumensoConfig(portId);
|
||||
|
||||
// Create the Documenso document from the locally-filled + flattened PDF.
|
||||
// Because the detail fields are flattened by pdf-lib (clean 12pt + multiline
|
||||
// address wrapping), Documenso never re-renders them — it only collects
|
||||
// signatures. This is what fixes the auto-sized/clipped detail text the
|
||||
// Documenso template-fill pathway produced.
|
||||
const documensoDoc = await documensoCreate(
|
||||
template.name,
|
||||
pdfBase64,
|
||||
@@ -724,10 +735,38 @@ async function generateAndSignViaInApp(
|
||||
role: s.role,
|
||||
signingOrder: s.signingOrder,
|
||||
})),
|
||||
portId,
|
||||
{
|
||||
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
// Place the EOI page-3 signature block. The flattened PDF carries no
|
||||
// Documenso fields, so place the six fields (client Signature/Name/
|
||||
// Place-of-Signing/Date, developer Name/Signature) at template 8's
|
||||
// coordinates, mapped by signing order (1 = client, 2 = developer; the
|
||||
// approver signs no fields).
|
||||
if (template.templateType === 'eoi') {
|
||||
const byOrder = new Map(documensoDoc.recipients.map((r) => [r.signingOrder, r.id]));
|
||||
const clientRecipientId = byOrder.get(1);
|
||||
const developerRecipientId = byOrder.get(2);
|
||||
if (clientRecipientId && developerRecipientId) {
|
||||
await documensoPlaceFields(
|
||||
documensoDoc.id,
|
||||
computeEoiSignatureLayout(clientRecipientId, developerRecipientId),
|
||||
portId,
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ docId: documensoDoc.id, recipients: documensoDoc.recipients.length },
|
||||
'EOI in-app pathway: could not resolve client/developer recipients for signature-field placement',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send document for signing
|
||||
await documensoSend(documensoDoc.id);
|
||||
await documensoSend(documensoDoc.id, portId);
|
||||
|
||||
// Update our document record with Documenso ID and status
|
||||
await db
|
||||
@@ -740,6 +779,46 @@ async function generateAndSignViaInApp(
|
||||
})
|
||||
.where(eq(documents.id, documentRecord.id));
|
||||
|
||||
// Persist per-recipient signer rows so the EOI tab's signing-progress panel
|
||||
// and the webhook handler (which matches by token / email) work — parity
|
||||
// with the documenso-template pathway. Strip the `(was: …)` /
|
||||
// `(placeholder)` suffixes EMAIL_REDIRECT_TO bakes into names.
|
||||
if (documensoDoc.recipients.length > 0) {
|
||||
await db.insert(documentSigners).values(
|
||||
documensoDoc.recipients.map((r) => {
|
||||
const cleanName = (r.name || r.email)
|
||||
.replace(/\s*\(was:[^)]*\)/i, '')
|
||||
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
|
||||
.trim();
|
||||
const role =
|
||||
r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1
|
||||
? 'client'
|
||||
: normalizeSignerRole(r.role);
|
||||
return {
|
||||
documentId: documentRecord.id,
|
||||
signerName: cleanName || r.email,
|
||||
signerEmail: r.email,
|
||||
signerRole: role,
|
||||
signingOrder: r.signingOrder,
|
||||
status: 'pending' as const,
|
||||
signingUrl: r.signingUrl ?? null,
|
||||
embeddedUrl: r.embeddedUrl ?? null,
|
||||
signingToken: r.token ?? null,
|
||||
invitedAt: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Stamp the interest's EOI milestone so the Overview tab flips to
|
||||
// "EOI sent / awaiting signatures" — parity with the template pathway.
|
||||
if (context.interestId) {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiDocStatus: 'sent', dateEoiSent: new Date(), updatedAt: new Date() })
|
||||
.where(eq(interests.id, context.interestId));
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
@@ -794,48 +873,152 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
// platform to one Documenso instance per CRM process.
|
||||
const docCfg = await getPortDocumensoConfig(portId);
|
||||
|
||||
// v2 prefillFields-by-ID emission requires a field-name → field-ID map
|
||||
// populated by the admin "Sync from Documenso" button. Absent (or partial)
|
||||
// map → payload skips prefillFields and v2 accepts the legacy formValues
|
||||
// shape via backward compat.
|
||||
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
|
||||
const fieldMap = await getEoiFieldMap(portId);
|
||||
|
||||
// Pick which side of the yacht's stored dimensions ships to Documenso.
|
||||
// Pick which side of the yacht's stored dimensions ships to the PDF.
|
||||
// The drawer's toggle drives this; if the caller omitted it, default to
|
||||
// whichever unit the rep originally typed in (yacht.lengthUnit). Legacy
|
||||
// yachts without a unit column default to 'ft'.
|
||||
const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft';
|
||||
|
||||
const payload = buildDocumensoPayload(
|
||||
eoiContext,
|
||||
// Document title used by both fill methods + the documents row.
|
||||
const docTitle = `Expression of Interest – ${eoiContext.client.fullName}`;
|
||||
|
||||
let documensoDoc;
|
||||
let localFileId: string | null = null;
|
||||
|
||||
if (docCfg.eoiFillMethod === 'local') {
|
||||
// LOCAL-FILL (default): fill + flatten the source PDF ourselves (pdf-lib,
|
||||
// fixed 12pt + multiline address wrapping), upload the flattened PDF to
|
||||
// Documenso as a document, and place ONLY the page-3 signature fields.
|
||||
// Documenso never renders the body text, so it can't auto-size/clip it —
|
||||
// this is the fix for the oversized/clipped detail fields the Documenso
|
||||
// template-fill produced. Still flows through Documenso for signing, so
|
||||
// branded invites, embedded signing, webhooks, and emails are unchanged.
|
||||
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, { dimensionUnit });
|
||||
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
const fileId = crypto.randomUUID();
|
||||
const storagePath = buildStoragePath(
|
||||
port?.slug ?? portId,
|
||||
'eoi',
|
||||
context.interestId,
|
||||
fileId,
|
||||
'pdf',
|
||||
);
|
||||
{
|
||||
interestId: context.interestId,
|
||||
clientRecipientId: docCfg.clientRecipientId,
|
||||
developerRecipientId: docCfg.developerRecipientId,
|
||||
approvalRecipientId: docCfg.approvalRecipientId,
|
||||
developerName: signers.developer.name,
|
||||
developerEmail: signers.developer.email,
|
||||
approverName: signers.approver.name,
|
||||
approverEmail: signers.approver.email,
|
||||
// Prefer per-port post-signing redirect (typically marketing-site
|
||||
// /sign/success on v2). Falls back to APP_URL on v1 / when unset.
|
||||
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
|
||||
// v2-only signing-order enforcement. v1 instances ignore this key.
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
dimensionUnit,
|
||||
},
|
||||
fieldMap,
|
||||
);
|
||||
const buffer = Buffer.from(pdfBytes);
|
||||
const backend = await getStorageBackend();
|
||||
await backend.put(storagePath, buffer, {
|
||||
contentType: 'application/pdf',
|
||||
sizeBytes: buffer.length,
|
||||
});
|
||||
}
|
||||
const [fileRecord] = await db
|
||||
.insert(files)
|
||||
.values({
|
||||
portId,
|
||||
clientId: context.clientId ?? null,
|
||||
filename: 'expression-of-interest.pdf',
|
||||
originalName: 'Expression of Interest.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: String(pdfBytes.byteLength),
|
||||
storagePath,
|
||||
storageBucket: env.MINIO_BUCKET,
|
||||
category: 'eoi',
|
||||
uploadedBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
localFileId = fileRecord!.id;
|
||||
|
||||
const documensoDoc = await documensoGenerateFromTemplate(
|
||||
docCfg.eoiTemplateId,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
portId,
|
||||
);
|
||||
const created = await documensoCreate(
|
||||
docTitle,
|
||||
Buffer.from(pdfBytes).toString('base64'),
|
||||
[
|
||||
{
|
||||
name: eoiContext.client.fullName,
|
||||
email: eoiContext.client.primaryEmail ?? '',
|
||||
role: 'signer',
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
name: signers.developer.name,
|
||||
email: signers.developer.email,
|
||||
role: 'signer',
|
||||
signingOrder: 2,
|
||||
},
|
||||
{
|
||||
name: signers.approver.name,
|
||||
email: signers.approver.email,
|
||||
role: 'approver',
|
||||
signingOrder: 3,
|
||||
},
|
||||
],
|
||||
portId,
|
||||
{
|
||||
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
// Record a documents row referencing the Documenso document. No local file -
|
||||
// Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere).
|
||||
// Place the six page-3 signature fields at template-8 coordinates, mapped
|
||||
// by signing order (1 = client, 2 = developer; approver signs no fields).
|
||||
const byOrder = new Map(created.recipients.map((r) => [r.signingOrder, r.id]));
|
||||
const clientRid = byOrder.get(1);
|
||||
const developerRid = byOrder.get(2);
|
||||
if (clientRid && developerRid) {
|
||||
await documensoPlaceFields(
|
||||
created.id,
|
||||
computeEoiSignatureLayout(clientRid, developerRid),
|
||||
portId,
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ docId: created.id, recipients: created.recipients.length },
|
||||
'EOI local-fill: could not resolve client/developer recipients for field placement',
|
||||
);
|
||||
}
|
||||
|
||||
// v2 envelopes don't return signing URLs until distribute; v1 returns them
|
||||
// on create. Distribute (suppressing Documenso's own emails via
|
||||
// distributionMethod:NONE on v2 / DRAFT-stays-quiet on v1) only when
|
||||
// they're missing, so document_signers.signing_url is populated for the
|
||||
// branded "Send invitation" flow regardless of API version.
|
||||
const needsDistribute = created.recipients.some((r) => !r.signingUrl);
|
||||
documensoDoc = needsDistribute ? await documensoSend(created.id, portId) : created;
|
||||
} else {
|
||||
// DOCUMENSO TEMPLATE FILL (legacy fallback, eoi_fill_method='documenso'):
|
||||
// Documenso fills the template's AcroForm fields from the payload. Note it
|
||||
// auto-sizes/clips long values — kept only as a per-port escape hatch.
|
||||
// v2 prefillFields-by-ID needs a field-name → field-ID map from the admin
|
||||
// "Sync from Documenso" button; absent it, v2 ignores the legacy formValues.
|
||||
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
|
||||
const fieldMap = await getEoiFieldMap(portId);
|
||||
const payload = buildDocumensoPayload(
|
||||
eoiContext,
|
||||
{
|
||||
interestId: context.interestId,
|
||||
clientRecipientId: docCfg.clientRecipientId,
|
||||
developerRecipientId: docCfg.developerRecipientId,
|
||||
approvalRecipientId: docCfg.approvalRecipientId,
|
||||
developerName: signers.developer.name,
|
||||
developerEmail: signers.developer.email,
|
||||
approverName: signers.approver.name,
|
||||
approverEmail: signers.approver.email,
|
||||
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
dimensionUnit,
|
||||
},
|
||||
fieldMap,
|
||||
);
|
||||
documensoDoc = await documensoGenerateFromTemplate(
|
||||
docCfg.eoiTemplateId,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
portId,
|
||||
);
|
||||
}
|
||||
|
||||
// Record a documents row referencing the Documenso document. Local-fill
|
||||
// attaches the flattened PDF we stored; template-fill has no local file
|
||||
// (Documenso owns the PDF; signed copy arrives via webhook).
|
||||
const [documentRecord] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
@@ -843,8 +1026,9 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
clientId: context.clientId ?? null,
|
||||
interestId: context.interestId,
|
||||
documentType: 'eoi',
|
||||
title: payload.title,
|
||||
title: docTitle,
|
||||
status: 'sent',
|
||||
fileId: localFileId,
|
||||
documensoId: documensoDoc.id,
|
||||
documensoNumericId: documensoDoc.numericId,
|
||||
isManualUpload: false,
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
import {
|
||||
sendSigningInvitation,
|
||||
sendSigningCompleted,
|
||||
sendSigningStatusNotification,
|
||||
transformSigningUrl,
|
||||
type SignerRole,
|
||||
} from '@/lib/services/document-signing-emails.service';
|
||||
import {
|
||||
@@ -1062,10 +1064,23 @@ export async function uploadSignedManually(
|
||||
export async function listDocumentSigners(documentId: string, portId: string) {
|
||||
await getDocumentById(documentId, portId); // verify access
|
||||
|
||||
return db.query.documentSigners.findMany({
|
||||
const rows = await db.query.documentSigners.findMany({
|
||||
where: eq(documentSigners.documentId, documentId),
|
||||
orderBy: (ds, { asc }) => [asc(ds.signingOrder)],
|
||||
});
|
||||
|
||||
// Surface the BRANDED marketing-site embed URL (the same wrap the
|
||||
// invitation email applies) rather than the bare Documenso link, so the
|
||||
// EOI tab's "Copy link" shares the on-brand signing page. No-op when the
|
||||
// port has no embeddedSigningHost configured (transformSigningUrl returns
|
||||
// the raw URL unchanged).
|
||||
const { embeddedSigningHost } = await getPortDocumensoConfig(portId);
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
signingUrl: r.signingUrl
|
||||
? transformSigningUrl(r.signingUrl, embeddedSigningHost, r.signerRole as SignerRole)
|
||||
: r.signingUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── List Events ──────────────────────────────────────────────────────────────
|
||||
@@ -1248,6 +1263,14 @@ export async function handleRecipientSigned(eventData: {
|
||||
'cascading "your turn" invite failed after recipient signed',
|
||||
);
|
||||
});
|
||||
|
||||
// Internal "who signed" alert to the port's signing-notification
|
||||
// recipients (admin + sales@). Fire-and-forget + fully guarded inside
|
||||
// the helper so it can't undo the signing that just succeeded.
|
||||
void notifySigningStatus(doc, 'signed', {
|
||||
name: signer.signerName,
|
||||
role: signer.signerRole,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,6 +1368,70 @@ async function sendCascadingInviteForNextSigner(doc: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the internal "signing progress" alert to the port's configured
|
||||
* signing-notification recipients (admin + sales@, etc). Self-contained
|
||||
* and fully guarded — a notification failure must never undo a signing /
|
||||
* completion side effect, so all errors are swallowed + logged. Resolves
|
||||
* the deal client name + a deep CRM link, and (for `signed`) the running
|
||||
* signed/total progress.
|
||||
*/
|
||||
async function notifySigningStatus(
|
||||
doc: Parameters<typeof resolveDocumentOwner>[1] & {
|
||||
id: string;
|
||||
portId: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
},
|
||||
event: 'signed' | 'completed',
|
||||
signer?: { name: string; role: string | null } | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, doc.portId),
|
||||
columns: { name: true, slug: true },
|
||||
});
|
||||
|
||||
let clientName = doc.title;
|
||||
const owner = await resolveDocumentOwner(doc.portId, doc);
|
||||
if (owner?.entityType === 'client') {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, owner.entityId),
|
||||
columns: { fullName: true },
|
||||
});
|
||||
if (client?.fullName) clientName = client.fullName;
|
||||
}
|
||||
|
||||
let signedCount: number | undefined;
|
||||
let totalCount: number | undefined;
|
||||
if (event === 'signed') {
|
||||
const all = await db
|
||||
.select({ status: documentSigners.status })
|
||||
.from(documentSigners)
|
||||
.where(eq(documentSigners.documentId, doc.id));
|
||||
totalCount = all.length;
|
||||
signedCount = all.filter((s) => s.status === 'signed').length;
|
||||
}
|
||||
|
||||
const crmUrl = `${env.APP_URL ?? ''}/${port?.slug ?? ''}/documents/${doc.id}`;
|
||||
|
||||
await sendSigningStatusNotification({
|
||||
portId: doc.portId,
|
||||
portName: port?.name ?? 'Port Nimara',
|
||||
event,
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
clientName,
|
||||
crmUrl,
|
||||
signerName: signer?.name ?? null,
|
||||
signerRole: (signer?.role as SignerRole) ?? null,
|
||||
signedCount,
|
||||
totalCount,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, documentId: doc.id, event }, 'signing status notification failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually (re)send the finalized signed PDF of a completed document to the
|
||||
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
|
||||
@@ -1924,6 +2011,11 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Internal "fully signed" alert to the port's signing-notification
|
||||
// recipients (admin + sales@). handleDocumentCompleted is idempotent
|
||||
// (early-returns on re-delivery), so this fires exactly once per doc.
|
||||
void notifySigningStatus(doc, 'completed');
|
||||
}
|
||||
|
||||
export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) {
|
||||
|
||||
@@ -303,6 +303,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const year = String(now.getFullYear());
|
||||
|
||||
// CM-2 Part B: deal-specific price override. The base berth price is the
|
||||
// canonical list price; an interest_berths.price_override (when set)
|
||||
// supersedes it for THIS interest's documents via the existing
|
||||
// {{berth.price}} / {{berth.priceCurrency}} tokens.
|
||||
let resolvedBerthPrice = berth?.price ?? null;
|
||||
let resolvedBerthCurrency = berth?.priceCurrency ?? port.defaultCurrency;
|
||||
if (berth && primaryBerthId) {
|
||||
const [ibOverride] = await db
|
||||
.select({
|
||||
priceOverride: interestBerths.priceOverride,
|
||||
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.where(
|
||||
and(eq(interestBerths.interestId, interest.id), eq(interestBerths.berthId, primaryBerthId)),
|
||||
);
|
||||
if (ibOverride?.priceOverride != null) {
|
||||
resolvedBerthPrice = ibOverride.priceOverride;
|
||||
resolvedBerthCurrency = ibOverride.priceOverrideCurrency ?? berth.priceCurrency;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
client: {
|
||||
id: client.id,
|
||||
@@ -337,8 +359,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
mooringNumber: berth.mooringNumber,
|
||||
area: berth.area,
|
||||
lengthFt: berth.lengthFt,
|
||||
price: berth.price,
|
||||
priceCurrency: berth.priceCurrency,
|
||||
price: resolvedBerthPrice,
|
||||
priceCurrency: resolvedBerthCurrency,
|
||||
tenureType: berth.tenureType,
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
|
||||
addedBy: interestBerths.addedBy,
|
||||
addedAt: interestBerths.addedAt,
|
||||
notes: interestBerths.notes,
|
||||
priceOverride: interestBerths.priceOverride,
|
||||
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
status: berths.status,
|
||||
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
|
||||
.delete(interestBerths)
|
||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
||||
}
|
||||
|
||||
// ─── Per-interest price override (CM-2 Part B) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the effective price for a berth in the context of an interest. The
|
||||
* deal-specific override (when set) supersedes the berth's canonical list
|
||||
* price; the override carries its own currency, falling back to the base
|
||||
* currency when null. Pure — safe to unit-test without a DB.
|
||||
*/
|
||||
export function resolveBerthPriceForInterest(
|
||||
override: { priceOverride: string | null; priceOverrideCurrency: string | null },
|
||||
base: { price: string | null; priceCurrency: string },
|
||||
): { price: string | null; currency: string } {
|
||||
if (override.priceOverride != null) {
|
||||
return {
|
||||
price: override.priceOverride,
|
||||
currency: override.priceOverrideCurrency ?? base.priceCurrency,
|
||||
};
|
||||
}
|
||||
return { price: base.price, currency: base.priceCurrency };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, when `price` is null) the deal-specific price for one
|
||||
* (interest, berth). Tenant-scoped: the interest must belong to `portId`.
|
||||
* Does not touch `berths.price`.
|
||||
*/
|
||||
export async function setBerthPriceOverride(
|
||||
interestId: string,
|
||||
berthId: string,
|
||||
price: number | null,
|
||||
currency: string | null,
|
||||
portId: string,
|
||||
): Promise<void> {
|
||||
const interestRow = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
});
|
||||
if (!interestRow) throw new NotFoundError('Interest');
|
||||
await db
|
||||
.update(interestBerths)
|
||||
.set({
|
||||
priceOverride: price == null ? null : String(price),
|
||||
priceOverrideCurrency: price == null ? null : (currency ?? 'USD'),
|
||||
})
|
||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
||||
}
|
||||
|
||||
@@ -833,7 +833,12 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
||||
// every new lead. Falls back to null (Unassigned) when none of
|
||||
// the above resolve.
|
||||
let resolvedAssignedTo = interestData.assignedTo ?? null;
|
||||
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
|
||||
// CM-5: tiers 2 & 3 (port default-owner + auto-assign-to-creator) only run
|
||||
// when the per-port assignment feature is enabled. Tier 1 (an explicit
|
||||
// assignedTo from the caller) is always honored. Default is OFF.
|
||||
const assignmentSetting = await getSetting('assignment_enabled', portId);
|
||||
const assignmentEnabled = assignmentSetting?.value === true;
|
||||
if (assignmentEnabled && resolvedAssignedTo === null && !('assignedTo' in interestData)) {
|
||||
const defaultOwner = await getSetting('default_new_interest_owner', portId);
|
||||
const v = defaultOwner?.value as { userId?: string } | null | undefined;
|
||||
if (v?.userId) {
|
||||
|
||||
67
src/lib/services/mailchimp.service.ts
Normal file
67
src/lib/services/mailchimp.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* CM-1: Mailchimp Marketing API integration (one-way push, CRM → Mailchimp).
|
||||
*
|
||||
* SCOPE NOTE: per the locked CM-1 decision, the exact group → tag/segment
|
||||
* mapping is finalised only once we have the client's actual Mailchimp account.
|
||||
* So this module ships the config plumbing + an inert sync that no-ops until
|
||||
* (a) an admin stores an API key + audience ID and (b) the mapping is wired.
|
||||
* The members viewer + copy-emails features do NOT depend on Mailchimp.
|
||||
*
|
||||
* Settings keys (per-port, in system_settings):
|
||||
* - `mailchimp_api_key` (AES-encrypted at rest, like SMTP/IMAP creds)
|
||||
* - `mailchimp_audience_id` (the single audience all groups map into)
|
||||
*/
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { decrypt } from '@/lib/utils/encryption';
|
||||
|
||||
export interface MailchimpConfig {
|
||||
apiKey: string;
|
||||
audienceId: string;
|
||||
/** Datacenter prefix derived from the key suffix (e.g. `us21`). */
|
||||
serverPrefix: string;
|
||||
}
|
||||
|
||||
/** Resolve + decrypt the per-port Mailchimp config, or null when unset. */
|
||||
export async function getMailchimpConfig(portId: string): Promise<MailchimpConfig | null> {
|
||||
const keyRow = await getSetting('mailchimp_api_key', portId);
|
||||
const audRow = await getSetting('mailchimp_audience_id', portId);
|
||||
const encKey = typeof keyRow?.value === 'string' ? keyRow.value : null;
|
||||
const audienceId = typeof audRow?.value === 'string' ? audRow.value : null;
|
||||
if (!encKey || !audienceId) return null;
|
||||
let apiKey: string;
|
||||
try {
|
||||
apiKey = decrypt(encKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Mailchimp keys are `<hex>-<dc>`; the datacenter is the API host prefix.
|
||||
const serverPrefix = apiKey.split('-')[1] ?? '';
|
||||
if (!serverPrefix) return null;
|
||||
return { apiKey, audienceId, serverPrefix };
|
||||
}
|
||||
|
||||
export async function isMailchimpConfigured(portId: string): Promise<boolean> {
|
||||
return (await getMailchimpConfig(portId)) !== null;
|
||||
}
|
||||
|
||||
export type MailchimpSyncResult = { skipped: string } | { synced: true; count: number };
|
||||
|
||||
/**
|
||||
* Push a group's members to Mailchimp as a tag/segment on the port's audience.
|
||||
* Inert until configured AND the mapping is confirmed (see SCOPE NOTE).
|
||||
*/
|
||||
export async function syncGroupToMailchimp(
|
||||
groupId: string,
|
||||
portId: string,
|
||||
): Promise<MailchimpSyncResult> {
|
||||
const config = await getMailchimpConfig(portId);
|
||||
if (!config) return { skipped: 'not-configured' };
|
||||
// TODO(CM-1): mapping pending the client's Mailchimp account. Once confirmed,
|
||||
// upsert each member via
|
||||
// PUT https://{serverPrefix}.api.mailchimp.com/3.0/lists/{audienceId}/members/{md5(lowercased-email)}
|
||||
// then apply the group's tag. Only push subscribed/opted-in contacts (GDPR).
|
||||
logger.info({ groupId, portId }, 'Mailchimp sync requested (mapping pending client account)');
|
||||
return { skipped: 'mapping-pending' };
|
||||
}
|
||||
@@ -37,6 +37,12 @@ export interface OcrConfigPublic {
|
||||
* provider is never called even if a key is configured.
|
||||
*/
|
||||
aiEnabled: boolean;
|
||||
/**
|
||||
* CM-6: manual-entry mode. When true the scanner skips ALL parsing
|
||||
* (Tesseract + AI) and presents an empty form for the operator to fill in
|
||||
* by hand. Per-port; takes precedence over `aiEnabled`. Default false.
|
||||
*/
|
||||
manualEntry: boolean;
|
||||
}
|
||||
|
||||
/** Internal shape including the decrypted key - server-side only. */
|
||||
@@ -52,6 +58,7 @@ interface StoredOcrConfig {
|
||||
apiKeyEncrypted: string | null;
|
||||
useGlobal: boolean;
|
||||
aiEnabled?: boolean;
|
||||
manualEntry?: boolean;
|
||||
}
|
||||
|
||||
const KEY = 'ocr.config';
|
||||
@@ -106,12 +113,14 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
|
||||
hasApiKey: false,
|
||||
useGlobal: portRow?.useGlobal === true,
|
||||
aiEnabled: false,
|
||||
manualEntry: portRow?.manualEntry === true,
|
||||
source: 'none',
|
||||
};
|
||||
}
|
||||
// The aiEnabled flag is per-port: even if the port falls back to a global
|
||||
// key, the port admin still has to flip the switch on this port.
|
||||
// The aiEnabled / manualEntry flags are per-port: even if the port falls back
|
||||
// to a global key, the port admin still has to flip these on this port.
|
||||
const aiEnabled = portRow?.aiEnabled === true;
|
||||
const manualEntry = portRow?.manualEntry === true;
|
||||
return {
|
||||
provider: sourceRow.provider,
|
||||
model: sourceRow.model,
|
||||
@@ -119,6 +128,7 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
|
||||
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
|
||||
useGlobal: portRow?.useGlobal === true,
|
||||
aiEnabled,
|
||||
manualEntry,
|
||||
source: useGlobal ? 'global' : 'port',
|
||||
};
|
||||
}
|
||||
@@ -133,6 +143,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
|
||||
hasApiKey: false,
|
||||
useGlobal: false,
|
||||
aiEnabled: false,
|
||||
manualEntry: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -141,6 +152,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
|
||||
hasApiKey: Boolean(row.apiKeyEncrypted),
|
||||
useGlobal: row.useGlobal,
|
||||
aiEnabled: row.aiEnabled === true,
|
||||
manualEntry: row.manualEntry === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +166,8 @@ export interface SaveOcrConfigInput {
|
||||
useGlobal?: boolean;
|
||||
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */
|
||||
aiEnabled?: boolean;
|
||||
/** Per-port toggle: manual entry (skip all parsing). Defaults to false. */
|
||||
manualEntry?: boolean;
|
||||
}
|
||||
|
||||
export async function saveOcrConfig(
|
||||
@@ -171,6 +185,9 @@ export async function saveOcrConfig(
|
||||
// AI is meaningful only at the port scope. Preserve the existing flag if the
|
||||
// caller didn't pass one (so toggling provider/model doesn't re-disable AI).
|
||||
const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false);
|
||||
// Manual entry is also port-only; preserve when the caller omits it.
|
||||
const manualEntry =
|
||||
portId === null ? false : (input.manualEntry ?? existing?.manualEntry ?? false);
|
||||
await writeRow(
|
||||
portId,
|
||||
{
|
||||
@@ -179,6 +196,7 @@ export async function saveOcrConfig(
|
||||
apiKeyEncrypted,
|
||||
useGlobal: portId === null ? false : Boolean(input.useGlobal),
|
||||
aiEnabled,
|
||||
manualEntry,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
@@ -49,6 +49,11 @@ export const SETTING_KEYS = {
|
||||
// timing-safe comparison.
|
||||
documensoWebhookSecret: 'documenso_webhook_secret',
|
||||
eoiDefaultPathway: 'eoi_default_pathway',
|
||||
// EOI body-text fill method: 'local' (CRM fills + flattens the PDF, clean
|
||||
// 12pt + multiline address wrap, Documenso signs only) vs 'documenso'
|
||||
// (legacy: Documenso fills the template AcroForm fields and auto-sizes /
|
||||
// clips them). Toggleable per-port in admin → Documenso.
|
||||
eoiFillMethod: 'eoi_fill_method',
|
||||
// Identity of the developer + approver that the template's static
|
||||
// recipient slots get filled with. Old system hardcoded these
|
||||
// (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys
|
||||
@@ -316,6 +321,16 @@ export interface PortDocumensoConfig {
|
||||
apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none';
|
||||
eoiTemplateId: number;
|
||||
defaultPathway: EoiPathway;
|
||||
/**
|
||||
* EOI body-text fill method:
|
||||
* - 'local' : CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
|
||||
* multiline address wrapping), then uploads the flattened PDF
|
||||
* to Documenso for signature placement only. Renders cleanly.
|
||||
* - 'documenso': legacy — Documenso fills the template's AcroForm fields via
|
||||
* the template-generate API (auto-sizes the text → clips it).
|
||||
* Toggleable per-port in admin → Documenso. Defaults to 'local'.
|
||||
*/
|
||||
eoiFillMethod: 'local' | 'documenso';
|
||||
/** Documenso template recipient slot IDs (per-instance numeric). */
|
||||
clientRecipientId: number;
|
||||
developerRecipientId: number;
|
||||
@@ -387,6 +402,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
developerRecipientId,
|
||||
approvalRecipientId,
|
||||
defaultPathway,
|
||||
eoiFillMethod,
|
||||
developerName,
|
||||
developerEmail,
|
||||
approverName,
|
||||
@@ -411,6 +427,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId),
|
||||
readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId),
|
||||
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
|
||||
readSetting<'local' | 'documenso'>(SETTING_KEYS.eoiFillMethod, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
|
||||
@@ -464,6 +481,9 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
approvalRecipientId:
|
||||
toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0,
|
||||
defaultPathway: defaultPathway ?? 'documenso-template',
|
||||
// Default to the local-fill method (clean render + address wrapping). Set
|
||||
// to 'documenso' per-port to fall back to Documenso's template AcroForm fill.
|
||||
eoiFillMethod: eoiFillMethod === 'documenso' ? 'documenso' : 'local',
|
||||
developerName: developerName ?? '',
|
||||
developerEmail: developerEmail ?? '',
|
||||
approverName: approverName ?? '',
|
||||
|
||||
164
src/lib/services/proxies.service.ts
Normal file
164
src/lib/services/proxies.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* CM-9: proxy / point-of-contact service.
|
||||
*
|
||||
* A proxy is a designated contact attached to a client, interest, or yacht
|
||||
* (one per entity). `resolveEffectiveProxy` picks the most specific for an
|
||||
* outbound-comms context via the chain yacht → interest → client. All
|
||||
* operations are port-scoped; the entity is verified to belong to the port.
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, interests, proxies, yachts } from '@/lib/db/schema';
|
||||
import type { Proxy } from '@/lib/db/schema';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import type { ProxyEntityType, SetProxyInput } from '@/lib/validators/proxies';
|
||||
|
||||
const norm = (v?: string | null): string | null => {
|
||||
const t = v?.trim();
|
||||
return t ? t : null;
|
||||
};
|
||||
|
||||
async function assertEntityInPort(
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
portId: string,
|
||||
): Promise<void> {
|
||||
let exists = false;
|
||||
if (entityType === 'client') {
|
||||
const [r] = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.id, entityId), eq(clients.portId, portId)))
|
||||
.limit(1);
|
||||
exists = !!r;
|
||||
} else if (entityType === 'interest') {
|
||||
const [r] = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.id, entityId), eq(interests.portId, portId)))
|
||||
.limit(1);
|
||||
exists = !!r;
|
||||
} else {
|
||||
const [r] = await db
|
||||
.select({ id: yachts.id })
|
||||
.from(yachts)
|
||||
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
|
||||
.limit(1);
|
||||
exists = !!r;
|
||||
}
|
||||
if (!exists) throw new NotFoundError(`${entityType} not found in this port`);
|
||||
}
|
||||
|
||||
export async function getProxy(
|
||||
portId: string,
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
): Promise<Proxy | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(proxies)
|
||||
.where(
|
||||
and(
|
||||
eq(proxies.portId, portId),
|
||||
eq(proxies.entityType, entityType),
|
||||
eq(proxies.entityId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function setProxy(
|
||||
portId: string,
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
data: SetProxyInput,
|
||||
meta: AuditMeta,
|
||||
): Promise<Proxy> {
|
||||
await assertEntityInPort(entityType, entityId, portId);
|
||||
const next = {
|
||||
name: data.name.trim(),
|
||||
email: norm(data.email),
|
||||
phone: norm(data.phone),
|
||||
relationship: norm(data.relationship),
|
||||
notes: norm(data.notes),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const [row] = await db
|
||||
.insert(proxies)
|
||||
.values({ portId, entityType, entityId, ...next })
|
||||
.onConflictDoUpdate({
|
||||
target: [proxies.portId, proxies.entityType, proxies.entityId],
|
||||
set: next,
|
||||
})
|
||||
.returning();
|
||||
if (!row) throw new NotFoundError('Failed to save proxy');
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'update',
|
||||
entityType: `proxy_${entityType}`,
|
||||
entityId,
|
||||
newValue: toAuditJson(next),
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function clearProxy(
|
||||
portId: string,
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
meta: AuditMeta,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.delete(proxies)
|
||||
.where(
|
||||
and(
|
||||
eq(proxies.portId, portId),
|
||||
eq(proxies.entityType, entityType),
|
||||
eq(proxies.entityId, entityId),
|
||||
),
|
||||
);
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'delete',
|
||||
entityType: `proxy_${entityType}`,
|
||||
entityId,
|
||||
});
|
||||
}
|
||||
|
||||
export interface EffectiveProxy {
|
||||
proxy: Proxy;
|
||||
/** Which level the proxy was resolved from. */
|
||||
source: ProxyEntityType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most specific proxy for an outbound-comms context.
|
||||
* Precedence: yacht override → interest override → client default.
|
||||
* Returns null when no proxy is set anywhere in the chain (caller falls back
|
||||
* to the client themselves).
|
||||
*/
|
||||
export async function resolveEffectiveProxy(args: {
|
||||
portId: string;
|
||||
clientId?: string | null;
|
||||
interestId?: string | null;
|
||||
yachtId?: string | null;
|
||||
}): Promise<EffectiveProxy | null> {
|
||||
const { portId, clientId, interestId, yachtId } = args;
|
||||
if (yachtId) {
|
||||
const p = await getProxy(portId, 'yacht', yachtId);
|
||||
if (p) return { proxy: p, source: 'yacht' };
|
||||
}
|
||||
if (interestId) {
|
||||
const p = await getProxy(portId, 'interest', interestId);
|
||||
if (p) return { proxy: p, source: 'interest' };
|
||||
}
|
||||
if (clientId) {
|
||||
const p = await getProxy(portId, 'client', clientId);
|
||||
if (p) return { proxy: p, source: 'client' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -153,11 +153,22 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
||||
});
|
||||
if (!role) throw new ValidationError('Invalid role ID');
|
||||
|
||||
// Two onboarding modes:
|
||||
// - setup-email (default when no password is supplied): provision the
|
||||
// account with a throwaway random password the admin never sees, then
|
||||
// email the user a link to set their own. The /set-password page
|
||||
// consumes the better-auth reset token.
|
||||
// - manual: the admin typed a password inline; use it verbatim.
|
||||
const useSetupEmail = data.sendSetupEmail ?? !data.password;
|
||||
const initialPassword = useSetupEmail
|
||||
? `${crypto.randomUUID()}${crypto.randomUUID()}`
|
||||
: data.password!;
|
||||
|
||||
// Create Better Auth user
|
||||
const authResult = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: data.email.toLowerCase(),
|
||||
password: data.password,
|
||||
password: initialPassword,
|
||||
name: data.name,
|
||||
},
|
||||
});
|
||||
@@ -199,6 +210,32 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
// Setup-email mode: dispatch a "set your password" link. Reuses better-auth's
|
||||
// password-reset token, which the existing /set-password page consumes. Done
|
||||
// after the role assignment so the user is fully provisioned the moment they
|
||||
// set their password. A send failure must not roll back the created account —
|
||||
// the admin can resend, or fall back to setting a password manually.
|
||||
if (useSetupEmail) {
|
||||
// Flag this recipient so the shared sendResetPassword callback renders the
|
||||
// welcome email rather than the "you requested a reset" copy.
|
||||
const { markPendingWelcome, consumePendingWelcome } =
|
||||
await import('@/lib/auth/pending-welcome');
|
||||
markPendingWelcome(data.email);
|
||||
try {
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email: data.email.toLowerCase(), redirectTo: '/set-password' },
|
||||
});
|
||||
} catch (err) {
|
||||
// Clear the flag if the reset never dispatched, so a later self-service
|
||||
// reset for this address isn't mistaken for a welcome.
|
||||
consumePendingWelcome(data.email);
|
||||
logger.error(
|
||||
{ err, userId: newUserId },
|
||||
'createUser: failed to send welcome / set-password email (account was still created)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return getUser(newUserId, portId);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,12 @@ export async function sendWebsiteSubmissionEmails(
|
||||
if (kind === 'residence_inquiry') {
|
||||
if (fields.email) {
|
||||
const confirmation = await residentialClientConfirmation(
|
||||
{ firstName: fields.firstName, contactEmail, portName },
|
||||
{
|
||||
firstName: fields.firstName,
|
||||
contactEmail,
|
||||
residenceTypes: fields.residenceTypes,
|
||||
portName,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
const subject = await resolveSubject({
|
||||
@@ -160,7 +165,14 @@ export async function sendWebsiteSubmissionEmails(
|
||||
fallback: confirmation.subject,
|
||||
tokens: { portName, recipientName: fields.firstName },
|
||||
});
|
||||
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
|
||||
await sendEmail(
|
||||
fields.email,
|
||||
subject,
|
||||
confirmation.html,
|
||||
undefined,
|
||||
confirmation.text,
|
||||
portId,
|
||||
);
|
||||
}
|
||||
|
||||
const recipients = await resolveRecipients(portId, 'residential_notification_recipients');
|
||||
@@ -170,6 +182,8 @@ export async function sendWebsiteSubmissionEmails(
|
||||
fullName: fields.fullName,
|
||||
email: fields.email,
|
||||
phone: fields.phone,
|
||||
residenceTypes: fields.residenceTypes,
|
||||
preferredContactMethod: fields.preferredContact ?? undefined,
|
||||
placeOfResidence: fields.placeOfResidence ?? undefined,
|
||||
notes: fields.comments ?? undefined,
|
||||
crmDeepLink: crmUrl,
|
||||
@@ -183,7 +197,7 @@ export async function sendWebsiteSubmissionEmails(
|
||||
fallback: alert.subject,
|
||||
tokens: { portName, clientName: fields.fullName, email: fields.email, phone: fields.phone },
|
||||
});
|
||||
await sendEmail(recipients, subject, alert.html, undefined, undefined, portId);
|
||||
await sendEmail(recipients, subject, alert.html, undefined, alert.text, portId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +220,11 @@ export async function sendWebsiteSubmissionEmails(
|
||||
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
|
||||
}
|
||||
|
||||
const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients');
|
||||
// Contact-form alerts go to their own recipient list (the website routed
|
||||
// them to hello@ separately from berth alerts). Falls back to
|
||||
// inquiry_notification_recipients only via the shared resolver's
|
||||
// inquiry_contact_email fallback when unset.
|
||||
const recipients = await resolveRecipients(portId, 'contact_notification_recipients');
|
||||
if (recipients.length > 0) {
|
||||
const alert = await contactFormSalesAlert(
|
||||
{
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface InquiryFields {
|
||||
comments: string | null;
|
||||
/** The contact form's `interest` (string or string[]) joined for display. */
|
||||
interestType: string | null;
|
||||
/** The residence form's `residence_types` multi-select (villa types chosen). */
|
||||
residenceTypes: string[];
|
||||
/** The register form's `method_of_contact` preference: 'email' | 'phone'. */
|
||||
preferredContact: 'email' | 'phone' | null;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
@@ -44,6 +48,20 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
|
||||
? rawInterest.filter((v): v is string => typeof v === 'string').join(', ') || null
|
||||
: str(rawInterest) || null;
|
||||
|
||||
// The residence form posts `residence_types` as an array of villa-type
|
||||
// strings. Defensively coerce a lone string to a single-item array so a
|
||||
// future single-select form variant still maps cleanly.
|
||||
const rawResidenceTypes = payload.residence_types;
|
||||
const residenceTypes = Array.isArray(rawResidenceTypes)
|
||||
? rawResidenceTypes.filter((v): v is string => typeof v === 'string' && v.trim() !== '')
|
||||
: str(rawResidenceTypes)
|
||||
? [str(rawResidenceTypes)]
|
||||
: [];
|
||||
|
||||
const methodOfContact = str(payload.method_of_contact).toLowerCase();
|
||||
const preferredContact =
|
||||
methodOfContact === 'email' ? 'email' : methodOfContact === 'phone' ? 'phone' : null;
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
@@ -54,5 +72,7 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
|
||||
placeOfResidence,
|
||||
comments,
|
||||
interestType,
|
||||
residenceTypes,
|
||||
preferredContact,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +204,20 @@ export const REGISTRY: SettingEntry[] = [
|
||||
scope: 'port',
|
||||
defaultValue: 'documenso-template',
|
||||
},
|
||||
{
|
||||
key: 'eoi_fill_method',
|
||||
section: 'documenso.templates',
|
||||
label: 'EOI form fill method',
|
||||
description:
|
||||
"How the EOI's detail fields (name, address, yacht, berth) get filled in. Local fill (recommended) = the CRM fills + flattens the PDF itself at a fixed 12pt with multiline address wrapping, then sends it to Documenso for signatures only — text renders cleanly. Documenso template fill = Documenso fills the template's form fields and auto-sizes the text, which oversizes/clips long values. Both still go through Documenso for signing, branded emails, and embedded signing.",
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ value: 'local', label: 'Local fill — clean text + address wrapping (recommended)' },
|
||||
{ value: 'documenso', label: 'Documenso template fill — legacy (may clip long values)' },
|
||||
],
|
||||
scope: 'port',
|
||||
defaultValue: 'local',
|
||||
},
|
||||
{
|
||||
key: 'eoi_send_mode',
|
||||
section: 'documenso.templates',
|
||||
@@ -688,7 +702,7 @@ export const REGISTRY: SettingEntry[] = [
|
||||
section: 'operations.intake',
|
||||
label: 'CRM-owned website inquiry emails',
|
||||
description:
|
||||
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients / residential_notification_recipients (fallback inquiry_contact_email).',
|
||||
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients (berth) / contact_notification_recipients (contact form) / residential_notification_recipients (residences), each falling back to inquiry_contact_email.',
|
||||
type: 'boolean',
|
||||
scope: 'port',
|
||||
defaultValue: false,
|
||||
|
||||
25
src/lib/validators/client-groups.ts
Normal file
25
src/lib/validators/client-groups.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** CM-1: client groups (mailing/segment lists). */
|
||||
|
||||
export const createClientGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Group name is required').max(120),
|
||||
description: z.string().trim().max(2000).nullish(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex value like #6B7280')
|
||||
.optional(),
|
||||
/** Mailchimp tag/segment name this group maps to. Null until wired up. */
|
||||
mailchimpTag: z.string().trim().max(200).nullish(),
|
||||
});
|
||||
|
||||
export const updateClientGroupSchema = createClientGroupSchema.partial();
|
||||
|
||||
/** Wipe-and-rewrite the group's membership to exactly this set of clients. */
|
||||
export const setGroupMembersSchema = z.object({
|
||||
clientIds: z.array(z.string().min(1)).max(5000),
|
||||
});
|
||||
|
||||
export type CreateClientGroupInput = z.infer<typeof createClientGroupSchema>;
|
||||
export type UpdateClientGroupInput = z.infer<typeof updateClientGroupSchema>;
|
||||
export type SetGroupMembersInput = z.infer<typeof setGroupMembersSchema>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user