chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -17,7 +17,7 @@ import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||
// `identifier` accepts either an email address or a username (3–30 lowercase
|
||||
// letters / digits / dot / underscore / hyphen). The server endpoint
|
||||
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
||||
// forwards to better-auth in one round-trip — the canonical email is never
|
||||
// forwards to better-auth in one round-trip - the canonical email is never
|
||||
// returned to the browser, which closes the username-enumeration vector.
|
||||
const loginSchema = z.object({
|
||||
identifier: z.string().min(1, 'Email or username is required'),
|
||||
@@ -61,7 +61,7 @@ export default function LoginPage() {
|
||||
if (payload.data?.needsBootstrap) router.replace('/setup');
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent — login UX must still work even if status check fails */
|
||||
/* silent - login UX must still work even if status check fails */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function ResetPasswordPage() {
|
||||
});
|
||||
|
||||
// Treat 400 "user not found" as success so we don't leak whether the
|
||||
// account exists — the success copy says "if an account exists…".
|
||||
// account exists - the success copy says "if an account exists…".
|
||||
// Anything else (5xx, network) surfaces as a real error.
|
||||
if (!response.ok && response.status !== 400) {
|
||||
toast.error('Something went wrong. Please try again.');
|
||||
|
||||
@@ -31,7 +31,7 @@ type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
||||
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
|
||||
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
|
||||
* carry `?token=…` and stay functional until every outstanding invite
|
||||
* expires — drop the `?token=` fallback after that grace period.
|
||||
* expires - drop the `?token=` fallback after that grace period.
|
||||
*/
|
||||
function readTokenFromUrl(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
@@ -31,7 +31,7 @@ interface StatusResp {
|
||||
/**
|
||||
* First-run setup. On a fresh DB the very first visitor can claim the
|
||||
* super-admin account here. Once anyone claims it, future visits to
|
||||
* /setup redirect back to /login — the precondition is verified both
|
||||
* /setup redirect back to /login - the precondition is verified both
|
||||
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
|
||||
* internal recheck) and client-side here.
|
||||
*/
|
||||
@@ -58,13 +58,13 @@ export default function SetupPage() {
|
||||
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
|
||||
if (cancelled) return;
|
||||
if (!res.data.needsBootstrap) {
|
||||
// Already initialized — bounce to login. Replace, not push,
|
||||
// Already initialized - bounce to login. Replace, not push,
|
||||
// so back-button doesn't trap the user here.
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Status endpoint failed — let the user try anyway; the POST
|
||||
// Status endpoint failed - let the user try anyway; the POST
|
||||
// does its own check and will surface a 409 if the window closed.
|
||||
} finally {
|
||||
if (!cancelled) setChecking(false);
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function AiAdminPage() {
|
||||
</Card>
|
||||
|
||||
{/*
|
||||
Berth-PDF parser AI fallback — currently configured via the
|
||||
Berth-PDF parser AI fallback - currently configured via the
|
||||
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
|
||||
when one is added, it lands here so admins don't have to hunt.
|
||||
*/}
|
||||
@@ -63,10 +63,10 @@ export default function AiAdminPage() {
|
||||
{/*
|
||||
Future AI surfaces. Each gets a section here once it ships:
|
||||
- Recommender embeddings (currently rule-based, not LLM-based)
|
||||
- Contact-log action extraction (deferred — needs user demand)
|
||||
- Contact-log action extraction (deferred - needs user demand)
|
||||
- Inquiry-form auto-classification (deferred)
|
||||
Listing them inert here closes the "where do I configure AI?"
|
||||
loop — admins land on /admin/ai and see the full landscape.
|
||||
loop - admins land on /admin/ai and see the full landscape.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
/**
|
||||
* Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed
|
||||
* pre-2026-05-22 but were only reachable via deep links from inside the
|
||||
* Berths list. Surfacing them on a dedicated admin landing tile so the
|
||||
* tools are discoverable without prior knowledge of the URL - part of
|
||||
* the admin IA regroup (B3 #10 Phase 2).
|
||||
*/
|
||||
export default async function BerthsAdminIndex({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
const tools = [
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/bulk-add` as Route,
|
||||
label: 'Bulk add berths',
|
||||
description:
|
||||
'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.',
|
||||
icon: Anchor,
|
||||
},
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/reconcile` as Route,
|
||||
label: 'Reconciliation queue',
|
||||
description:
|
||||
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||
icon: FileSearch,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berths admin"
|
||||
eyebrow="ADMIN"
|
||||
description="Tools for bulk berth creation and post-import reconciliation. Single-berth edits stay on the Berths list - these surfaces are for batch operations."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{tools.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Link key={t.href} href={t.href} className="block group">
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon
|
||||
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<CardTitle className="text-base">{t.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{t.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card className="border-amber-200 bg-amber-50/50">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<AlertCircle className="h-5 w-5 mt-0.5 text-amber-600" aria-hidden />
|
||||
<CardTitle className="text-sm">Not what you're looking for?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs">
|
||||
For single-berth edits, browse to the{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/berths` as Route}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Berths list
|
||||
</Link>{' '}
|
||||
and click any row. Per-berth PDF uploads + brochure assignment also live there.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
description:
|
||||
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
||||
type: 'image-upload',
|
||||
// 16:9 — landscape. Without an explicit aspect, the cropper falls
|
||||
// 16:9 - landscape. Without an explicit aspect, the cropper falls
|
||||
// back to 1:1 and renders a circular mask (intended for avatars),
|
||||
// which is the wrong UX for a viewport-cover background.
|
||||
imageAspect: 16 / 9,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
|
||||
*
|
||||
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
||||
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
||||
* body-size limit — see §11.1), and toggle the default flag.
|
||||
* body-size limit - see §11.1), and toggle the default flag.
|
||||
*/
|
||||
export default function BrochuresAdminPage() {
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
// All field arrays removed — every Documenso setting now flows through
|
||||
// All field arrays removed - every Documenso setting now flows through
|
||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||
// source badge on each field. The settings themselves live in
|
||||
// `src/lib/settings/registry.ts` under sections `documenso.api` /
|
||||
@@ -17,8 +17,8 @@ export default function DocumensoSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Documenso & EOI"
|
||||
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
|
||||
title="Signing service (Documenso)"
|
||||
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."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
@@ -200,7 +200,7 @@ export default function DocumensoSettingsPage() {
|
||||
<RegistryDrivenForm
|
||||
sections={['documenso.templates']}
|
||||
title="Templates & signing pathway"
|
||||
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
||||
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
||||
extra={<TemplateSyncButton />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
|
||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
||||
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
||||
import { TestTemplateCard } from '@/components/admin/email/test-template-card';
|
||||
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
@@ -14,7 +15,7 @@ export default function EmailSettingsPage() {
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
||||
/>
|
||||
|
||||
{/* Explainer for the "two accounts" model — addresses the recurring
|
||||
{/* Explainer for the "two accounts" model - addresses the recurring
|
||||
UAT question "why are there separate SMTP credentials for sales
|
||||
and noreply?". Keeps the answer in front of the admin before
|
||||
they reach the per-card form below. */}
|
||||
@@ -39,7 +40,7 @@ export default function EmailSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Registry-driven so each field shows the "Using env fallback /
|
||||
port / global / default" badge inline — admins can tell at a
|
||||
port / global / default" badge inline - admins can tell at a
|
||||
glance which fields are coming from .env vs. UI overrides. */}
|
||||
<RegistryDrivenForm
|
||||
sections={['email.from']}
|
||||
@@ -52,6 +53,7 @@ export default function EmailSettingsPage() {
|
||||
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
||||
/>
|
||||
<SmtpTestSendCard />
|
||||
<TestTemplateCard />
|
||||
<SalesEmailConfigCard />
|
||||
<EmailRoutingCard />
|
||||
</div>
|
||||
|
||||
@@ -163,11 +163,11 @@ export default function ErrorEventDetailPage() {
|
||||
<KV label="Method" value={event.method} />
|
||||
<KV label="Path" value={event.path} mono />
|
||||
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '—'} />
|
||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '-'} />
|
||||
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
||||
<KV label="User" value={event.userId ?? '(none)'} mono />
|
||||
<KV label="IP" value={event.ipAddress ?? '—'} mono />
|
||||
<KV label="User agent" value={event.userAgent ?? '—'} />
|
||||
<KV label="IP" value={event.ipAddress ?? '-'} mono />
|
||||
<KV label="User agent" value={event.userAgent ?? '-'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -176,11 +176,11 @@ export default function ErrorEventDetailPage() {
|
||||
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<KV label="Name" value={event.errorName ?? '—'} mono />
|
||||
<KV label="Name" value={event.errorName ?? '-'} mono />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Message</p>
|
||||
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.errorMessage ?? '—'}
|
||||
{event.errorMessage ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
{event.errorStack && (
|
||||
@@ -240,7 +240,7 @@ function KV({ label, value, mono }: { label: string; value: string | null; mono?
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '—'}</p>
|
||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ERROR_CODES } from '@/lib/error-codes';
|
||||
* plain-language meaning + status code without leaving the app.
|
||||
*
|
||||
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
||||
* automatically — adding an entry to the registry adds a row here.
|
||||
* automatically - adding an entry to the registry adds a row here.
|
||||
*/
|
||||
export default function ErrorCodeReferencePage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
@@ -39,7 +39,7 @@ export default function ErrorCodeReferencePage() {
|
||||
}, [search]);
|
||||
|
||||
// Group by domain prefix (the part before the first underscore) so
|
||||
// the table reads naturally — Expenses, Berths, Storage, etc.
|
||||
// the table reads naturally - Expenses, Berths, Storage, etc.
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function OcrSettingsPage() {
|
||||
return <OcrSettingsForm />;
|
||||
/**
|
||||
* Legacy route. OCR settings now live on the consolidated AI panel at
|
||||
* `/admin/ai` (the same `<OcrSettingsForm>` is mounted there alongside
|
||||
* the master AI switch + provider credentials). Kept as a redirect-only
|
||||
* page so any bookmarks / docs / deep links land on the right surface.
|
||||
*
|
||||
* Slated for full removal once the 2026-05-22 admin IA migration has
|
||||
* had a quarter to bed in.
|
||||
*/
|
||||
export default async function OcrLegacyRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/admin/ai`);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function PipelineRulesPage() {
|
||||
});
|
||||
|
||||
// Hydrate the local form once the server-side state arrives. We treat
|
||||
// missing keys as the registered default — the page's persisted JSON
|
||||
// missing keys as the registered default - the page's persisted JSON
|
||||
// doesn't have to enumerate every trigger, just the overrides.
|
||||
useEffect(() => {
|
||||
const persisted = data?.data?.values?.stage_advance_rules?.value;
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
return <ReportsDashboard />;
|
||||
/**
|
||||
* 2026-05-22: `/admin/reports` deleted. The page rendered three cards
|
||||
* - Pipeline funnel, Berth occupancy, and a KPI grid - all of which
|
||||
* are already covered by the main Dashboard widgets (`pipeline_funnel`,
|
||||
* `occupancy_timeline`, `kpi_*`). Redirecting to the dashboard so any
|
||||
* lingering bookmarks land somewhere coherent.
|
||||
*
|
||||
* The `<ReportsDashboard>` component file lives on in the repo for now
|
||||
* pending a follow-up sweep - once we confirm no other surface mounts
|
||||
* it, the component + its data hook can be removed too.
|
||||
*/
|
||||
export default async function ReportsLegacyRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/dashboard`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function ResidentialStagesPage() {
|
||||
/>
|
||||
<ResidentialStagesAdmin />
|
||||
|
||||
{/* Partner forwarding — sits on the same admin page so all
|
||||
{/* Partner forwarding - sits on the same admin page so all
|
||||
residential-only port settings live in one place. Reps still
|
||||
see every inquiry in the CRM; this is an outbound courtesy
|
||||
notification for the partner who handles residential leads. */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TemplateEditor } from '@/components/admin/templates/template-editor';
|
||||
|
||||
/**
|
||||
* Phase 7.1 — PDF template editor (read + place markers).
|
||||
* Phase 7.1 - PDF template editor (read + place markers).
|
||||
*
|
||||
* Renders the source PDF for the selected template and lets the admin
|
||||
* drop merge-field markers by clicking on the page. Persists the marker
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
/**
|
||||
* "People with access" surface — covers BOTH currently-active CRM users
|
||||
* "People with access" surface - covers BOTH currently-active CRM users
|
||||
* and pending invitations. Previously these lived on separate routes
|
||||
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
|
||||
* on one page and tab between states. The standalone /admin/invitations
|
||||
|
||||
@@ -50,15 +50,15 @@ const FIELDS: SettingFieldDef[] = [
|
||||
},
|
||||
{
|
||||
key: 'umami_api_token',
|
||||
label: 'API key (Umami Cloud only — optional)',
|
||||
label: 'API key (Umami Cloud only - optional)',
|
||||
description:
|
||||
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs - the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
|
||||
// Tracking-pixel kill switch - opt-in per port. When enabled, outbound
|
||||
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
||||
// records opens to `document_send_opens` and cross-posts to Umami.
|
||||
const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||
@@ -66,7 +66,7 @@ const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||
key: 'email_open_tracking_enabled',
|
||||
label: 'Track email opens',
|
||||
description:
|
||||
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
|
||||
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count - standard email-tracking caveats apply.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Legacy /alerts route — merged into /inbox in 2026-05-11. The hash
|
||||
// Legacy /alerts route - merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Alerts section on the merged page, so old
|
||||
// bookmarks land in the right spot.
|
||||
export default async function AlertsRedirect({
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ScanReceiptPage() {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
|
||||
// so the expense links to the actual image. The legacy scanner skipped
|
||||
// this step and saved expenses without their receipt — which silently
|
||||
// this step and saved expenses without their receipt - which silently
|
||||
// disqualified them from parent-company reimbursement (the warning the
|
||||
// PDF export now surfaces).
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
|
||||
@@ -365,7 +365,7 @@ export default function ScanReceiptPage() {
|
||||
disabled={
|
||||
saveMutation.isPending ||
|
||||
!amount ||
|
||||
// Block save while the receipt upload is still in flight —
|
||||
// Block save while the receipt upload is still in flight -
|
||||
// otherwise the rep can hit Save before the storage round
|
||||
// trip finishes and the expense lands without `receiptFileIds`,
|
||||
// silently re-creating the legacy receipt-loss bug.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Legacy /reminders route — merged into /inbox in 2026-05-11. The hash
|
||||
// Legacy /reminders route - merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Reminders section on the merged page.
|
||||
export default async function RemindersRedirect({
|
||||
params,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* /<port>/residential is a namespace segment — the actual landing is
|
||||
* /<port>/residential is a namespace segment - the actual landing is
|
||||
* /residential/clients. Without a page.tsx here, the breadcrumb's
|
||||
* "Residential" link 404s. Server-redirect to the Clients sub-page so
|
||||
* the link works as a useful shortcut.
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
: portRoles.map((pr) => pr.port);
|
||||
|
||||
// Prefer a previously-resolved tier from the client's cookie so the
|
||||
// server renders the matching shell on first paint — eliminates the
|
||||
// server renders the matching shell on first paint - eliminates the
|
||||
// mobile↔desktop chrome flicker that happens when UA-based classification
|
||||
// disagrees with the actual viewport (e.g. macOS Safari with the
|
||||
// window dragged below 1024). AppShell writes the cookie after the
|
||||
@@ -58,7 +58,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
|
||||
// Per-port logo map for the sidebar. Resolved server-side so the
|
||||
// sidebar can swap brand on port switch without an extra round-trip.
|
||||
// Falls back to null per port when no logo is configured — the
|
||||
// Falls back to null per port when no logo is configured - the
|
||||
// sidebar surfaces nothing rather than leaking a generic placeholder.
|
||||
const portBrandingEntries = await Promise.all(
|
||||
ports.map(async (p) => {
|
||||
@@ -85,7 +85,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
flag in prod) so the banner is dev/staging-only. */}
|
||||
<DevModeBanner />
|
||||
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
||||
* mobile) per render — never both — so pages don't pay the
|
||||
* mobile) per render - never both - so pages don't pay the
|
||||
* double-state, double-fetch, double-Tabs-provider tax. */}
|
||||
<AppShell
|
||||
portRoles={portRoles}
|
||||
|
||||
@@ -60,7 +60,7 @@ export default async function PortalLayout({ children }: { children: React.React
|
||||
// Branding for the auth-shell pages (login, forgot-password, reset).
|
||||
// When the visitor has a session, use that port's branding so they
|
||||
// stay inside one tenant's look. Otherwise pick up the first-port
|
||||
// default — the same path the CRM auth pages take.
|
||||
// default - the same path the CRM auth pages take.
|
||||
const branding = session
|
||||
? await getPortBrandingConfig(session.portId)
|
||||
.then((cfg) => ({
|
||||
|
||||
@@ -85,7 +85,7 @@ export default async function PortalInterestsPage() {
|
||||
)}
|
||||
</div>
|
||||
{/* leadCategory ("hot_lead" / "qualified_lead" / etc.)
|
||||
is a staff classification — never render to clients.
|
||||
is a staff classification - never render to clients.
|
||||
Privacy + optics: we shouldn't be telling the
|
||||
prospect they're a "hot lead". */}
|
||||
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
* Validate the `?next=` post-login redirect target. auth-flow-auditor M10:
|
||||
* an unvalidated `next` lets `/portal/login?next=https://evil.example`
|
||||
* navigate cross-site after sign-in. Only allow same-origin paths
|
||||
* scoped to the portal surface — anything else falls back to the
|
||||
* scoped to the portal surface - anything else falls back to the
|
||||
* dashboard.
|
||||
*/
|
||||
function safeNextPath(raw: string | null): string {
|
||||
|
||||
@@ -10,7 +10,7 @@ const upstream = toNextJsHandler(auth);
|
||||
/**
|
||||
* Wrap better-auth's `[...all]` handler so we can stamp the audit log on
|
||||
* authentication events. Better-auth itself doesn't fire any callback we
|
||||
* can hook on sign-in / sign-out / failed-login — we inspect the route
|
||||
* can hook on sign-in / sign-out / failed-login - we inspect the route
|
||||
* + response status after the upstream handler finishes.
|
||||
*
|
||||
* Successful sign-in → action 'login' (severity info)
|
||||
|
||||
@@ -12,7 +12,7 @@ const bodySchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// 10/hour/IP — bounds brute-force against either token store.
|
||||
// 10/hour/IP - bounds brute-force against either token store.
|
||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||
if (limited) return limited;
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// `auth.api.resetPassword` (rotates the password on an existing
|
||||
// user).
|
||||
// Try the CRM-invite path first. If the token isn't in that table
|
||||
// (NotFoundError), fall through to better-auth — these are mutually
|
||||
// (NotFoundError), fall through to better-auth - these are mutually
|
||||
// exclusive token spaces, so at most one will accept it.
|
||||
try {
|
||||
const result = await consumeCrmInvite({ token, password });
|
||||
|
||||
@@ -41,7 +41,7 @@ async function resolveToEmail(identifier: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses.
|
||||
// Rate-limit on IP - same 5/15min bucket the sign-in endpoint uses.
|
||||
const ip = clientIp(req);
|
||||
const rl = await checkRateLimit(ip, rateLimiters.auth);
|
||||
if (!rl.allowed) {
|
||||
|
||||
@@ -11,7 +11,7 @@ const bodySchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// 10/hour/IP — bounds brute-force against the 32-byte activation token.
|
||||
// 10/hour/IP - bounds brute-force against the 32-byte activation token.
|
||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||
if (limited) return limited;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
||||
const bodySchema = z.object({ email: z.string().email() });
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// 3/hour/IP — tightest of the portal limiters because each successful
|
||||
// 3/hour/IP - tightest of the portal limiters because each successful
|
||||
// call sends an outbound email and timing differences here are the
|
||||
// primary email-enumeration vector.
|
||||
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
||||
|
||||
@@ -11,7 +11,7 @@ const bodySchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// 10/hour/IP — bounds brute-force against the 32-byte reset token.
|
||||
// 10/hour/IP - bounds brute-force against the 32-byte reset token.
|
||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||
if (limited) return limited;
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
}
|
||||
|
||||
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
||||
// the limiter is per-account-per-IP, not just per-IP — a NATed network
|
||||
// the limiter is per-account-per-IP, not just per-IP - a NATed network
|
||||
// shouldn't be able to lock a single victim by burning their bucket.
|
||||
const limited = await enforcePublicRateLimit(
|
||||
req,
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function GET(request: Request): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Active berths for the port — retired moorings are hidden via
|
||||
// 1. Active berths for the port - retired moorings are hidden via
|
||||
// the archived_at soft-delete column (migration 0065).
|
||||
const berthRows = await db
|
||||
.select()
|
||||
|
||||
@@ -36,7 +36,7 @@ function gifResponse(): NextResponse {
|
||||
headers: {
|
||||
'Content-Type': 'image/gif',
|
||||
'Content-Length': String(TRANSPARENT_GIF.length),
|
||||
// Tell every upstream cache to keep its hands off — we count opens
|
||||
// Tell every upstream cache to keep its hands off - we count opens
|
||||
// on the FETCH itself, so any cached response is a missed open.
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||
Pragma: 'no-cache',
|
||||
@@ -62,7 +62,7 @@ export async function GET(
|
||||
const userAgent = req.headers.get('user-agent');
|
||||
const referer = req.headers.get('referer');
|
||||
|
||||
// Best-effort write — never block the pixel response on a slow DB.
|
||||
// Best-effort write - never block the pixel response on a slow DB.
|
||||
// The pixel must return promptly so email clients render normally.
|
||||
db.insert(documentSendOpens)
|
||||
.values({
|
||||
@@ -85,7 +85,7 @@ export async function GET(
|
||||
});
|
||||
|
||||
// Cross-post to Umami so the marketing funnel includes opens. Don't
|
||||
// await — fire-and-forget so the pixel response stays fast.
|
||||
// await - fire-and-forget so the pixel response stays fast.
|
||||
trackEvent(
|
||||
sendRow.portId,
|
||||
'email-opened',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Public, unauthenticated stream-by-id for branding assets only. Used by
|
||||
* outbound email templates and the branded auth shell — surfaces where
|
||||
* outbound email templates and the branded auth shell - surfaces where
|
||||
* the consumer can't authenticate (an inbox image fetch has no session
|
||||
* cookie). The `category = 'branding'` gate ensures only assets the
|
||||
* admin explicitly uploaded as port branding leak through this surface;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { logger } from '@/lib/logger';
|
||||
* the marketing site uses on startup AND what k8s readiness
|
||||
* probes should hit, because it returns 503 on hard dep failures.
|
||||
*
|
||||
* The dep checks (DB SELECT 1, Redis PING) run on every request — they
|
||||
* The dep checks (DB SELECT 1, Redis PING) run on every request - they
|
||||
* are <5ms each. If either fails, the response is 503 so a load balancer
|
||||
* stops routing to this instance.
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ async function gateRateLimit(ip: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/public/interests — unauthenticated public interest registration.
|
||||
// POST /api/public/interests - unauthenticated public interest registration.
|
||||
// The transactional trio creation (client + yacht + interest, plus optional
|
||||
// company + membership) lives in `createPublicInterest()` so it's testable
|
||||
// without an HTTP fixture. This handler is the thin HTTP shell: rate-limit,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms.
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Public — no auth. Loads the prefill data for the form. The token in
|
||||
* Public - no auth. Loads the prefill data for the form. The token in
|
||||
* the URL is the only credential; rejects expired / unknown tokens with
|
||||
* 404 (deliberately conflated to avoid leaking which tokens exist).
|
||||
*/
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function POST(req: NextRequest) {
|
||||
return errorResponse(new RateLimitError(retryAfter));
|
||||
}
|
||||
|
||||
// Parse + validate body. Reject anything that doesn't conform — the
|
||||
// Parse + validate body. Reject anything that doesn't conform - the
|
||||
// website is a known caller; a malformed payload signals tampering.
|
||||
let parsed;
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ interface ReadyResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe — verifies that every backing service this process
|
||||
* Readiness probe - verifies that every backing service this process
|
||||
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||
* load balancer until the next probe succeeds; it should not trigger a
|
||||
* pod restart (that's what `/api/health` is for).
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function GET(
|
||||
// Single-use enforcement. SET NX with a TTL pinned to the token's own
|
||||
// expiry so the dedup window never closes before the token does. Using
|
||||
// the body half of the token as the dedup key (signature included
|
||||
// would also work but body is enough — a reused token has the same body).
|
||||
// would also work but body is enough - a reused token has the same body).
|
||||
const replayKey = `storage:proxy:seen:${token.split('.')[0]}`;
|
||||
const remainingSeconds = Math.max(
|
||||
REPLAY_TTL_FLOOR_SECONDS,
|
||||
@@ -109,7 +109,7 @@ export async function GET(
|
||||
headers.set('Content-Type', payload.c ?? 'application/octet-stream');
|
||||
headers.set('Content-Length', String(size));
|
||||
if (payload.f) {
|
||||
// RFC 5987 — quote the filename and provide a UTF-8 fallback.
|
||||
// RFC 5987 - quote the filename and provide a UTF-8 fallback.
|
||||
const safe = payload.f.replace(/"/g, '');
|
||||
headers.set(
|
||||
'Content-Disposition',
|
||||
@@ -126,7 +126,7 @@ export async function GET(
|
||||
* Filesystem-backend upload proxy. The presigned URL minted by
|
||||
* `FilesystemBackend.presignUpload` points here. Without this handler the
|
||||
* browser-driven berth-PDF / brochure uploads would 405 in filesystem
|
||||
* deployments — the entire pluggable-storage abstraction relied on the
|
||||
* deployments - the entire pluggable-storage abstraction relied on the
|
||||
* GET-only counterpart for downloads.
|
||||
*
|
||||
* Same token-verify + single-use replay protection as GET, plus:
|
||||
@@ -186,7 +186,7 @@ export async function PUT(
|
||||
}
|
||||
|
||||
// Read the body into a buffer with a hard cap. Filesystem deployments are
|
||||
// small-tenant (single-node only — see FilesystemBackend boot guard) so
|
||||
// small-tenant (single-node only - see FilesystemBackend boot guard) so
|
||||
// 50 MB ceiling fits comfortably in heap; no streaming needed.
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
@@ -216,7 +216,7 @@ export async function PUT(
|
||||
}
|
||||
|
||||
// Magic-byte gate: when the token was minted with `c=application/pdf`
|
||||
// (the only consumer today — berth PDFs + brochures), refuse anything
|
||||
// (the only consumer today - berth PDFs + brochures), refuse anything
|
||||
// that isn't actually a PDF. Mirrors the post-upload check in
|
||||
// berth-pdf.service.ts so the two paths behave identically.
|
||||
if (payload.c === 'application/pdf' && !isPdfMagic(buffer)) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
||||
|
||||
/**
|
||||
* M-AU03 — CSV export of audit log search results.
|
||||
* M-AU03 - CSV export of audit log search results.
|
||||
*
|
||||
* Accepts the same query-string filters as `GET /api/v1/admin/audit`
|
||||
* (q, userId, action, entityType, entityId, severity, source, from, to)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { renderShell } from '@/lib/email/shell';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
|
||||
const SAMPLE_SUBJECT_SUFFIX = ' — branding preview';
|
||||
const SAMPLE_SUBJECT_SUFFIX = ' - branding preview';
|
||||
|
||||
function buildSampleEmail(branding: {
|
||||
logoUrl: string | null;
|
||||
@@ -51,7 +51,7 @@ function buildSampleEmail(branding: {
|
||||
return { subject, html };
|
||||
}
|
||||
|
||||
// GET — return the sample email rendered with the current port's branding.
|
||||
// GET - return the sample email rendered with the current port's branding.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
@@ -69,7 +69,7 @@ const sendTestSchema = z.object({
|
||||
recipient: z.string().email('Enter a valid email address'),
|
||||
});
|
||||
|
||||
// POST — actually send the sample email to a single recipient.
|
||||
// POST - actually send the sample email to a single recipient.
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
|
||||
@@ -49,7 +49,7 @@ export const GET = withAuth(
|
||||
if (!file) {
|
||||
return NextResponse.json({ data: null });
|
||||
}
|
||||
// Path-only — the admin UI renders this as `<img src>` and the
|
||||
// Path-only - the admin UI renders this as `<img src>` and the
|
||||
// browser resolves against the current origin. Stays valid whether
|
||||
// the admin opens the page from localhost or a LAN IP.
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -11,8 +11,8 @@ import { registerBrochureVersionSchema } from '@/lib/validators/brochures';
|
||||
|
||||
/**
|
||||
* Two-step upload (per §11.1):
|
||||
* 1. GET (no body) — server returns a fresh storage key + presigned URL.
|
||||
* 2. POST (metadata) — after the browser PUTs to the URL, register the
|
||||
* 1. GET (no body) - server returns a fresh storage key + presigned URL.
|
||||
* 2. POST (metadata) - after the browser PUTs to the URL, register the
|
||||
* version row server-side.
|
||||
*
|
||||
* Direct-to-storage uploads bypass Next.js's body-size limit; the server
|
||||
@@ -47,7 +47,7 @@ export const GET = withAuth(
|
||||
);
|
||||
|
||||
// Storage keys generated by `generateBrochureStorageKey` look like
|
||||
// `<portSlug>/brochures/<brochureId>/<uuid>.pdf`. Reject anything else —
|
||||
// `<portSlug>/brochures/<brochureId>/<uuid>.pdf`. Reject anything else -
|
||||
// without this, an admin holding manage_settings on port A could ship a
|
||||
// foreign port's storage key (signed EOI bytes, another port's brochure)
|
||||
// and have registerBrochureVersion repoint THIS port's brochure version
|
||||
|
||||
@@ -13,7 +13,7 @@ export const PATCH = withAuth(
|
||||
|
||||
// Read raw body before parsing so we can inspect `fieldType`
|
||||
// (the schema strips it; the service rejects any change). Using
|
||||
// req.json() directly here is intentional — parseBody would lose
|
||||
// req.json() directly here is intentional - parseBody would lose
|
||||
// the raw view we need for the mutation-attempt detection below.
|
||||
const body = (await req.json()) as Record<string, unknown>;
|
||||
const data = updateFieldSchema.parse(body);
|
||||
|
||||
@@ -87,7 +87,7 @@ export const GET = withAuth(
|
||||
),
|
||||
),
|
||||
// "completed30d" = interests that hit a terminal outcome in
|
||||
// the last 30 days (any outcome — won, lost, or cancelled).
|
||||
// the last 30 days (any outcome - won, lost, or cancelled).
|
||||
// Use `outcome_at` not `updated_at` so unrelated edits to a
|
||||
// long-closed deal don't drag it back into the window.
|
||||
db
|
||||
|
||||
@@ -13,7 +13,7 @@ import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.se
|
||||
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
|
||||
*
|
||||
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
|
||||
* ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows,
|
||||
* ID (`envelope_xxxxxxxx`) - the latter is what the Documenso UI URL shows,
|
||||
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
|
||||
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
|
||||
* before the sync runs.
|
||||
@@ -30,7 +30,7 @@ export const POST = withAuth(
|
||||
if (/^envelope_/.test(raw)) {
|
||||
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
|
||||
if (!resolved) {
|
||||
throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`);
|
||||
throw new NotFoundError(`Template "${raw}" - no matching envelopeId found`);
|
||||
}
|
||||
templateId = resolved;
|
||||
} else {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync
|
||||
* so the admin panel's status box survives a page reload without re-hitting
|
||||
* Documenso. Returns `{ data: null }` when no sync has run for this port.
|
||||
*
|
||||
* Admin-only via `admin.manage_settings` — same gate as the sync write
|
||||
* Admin-only via `admin.manage_settings` - same gate as the sync write
|
||||
* endpoint, since the report contains template recipient identities and
|
||||
* AcroForm field names that aren't OK to leak outside the admin surface.
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ import { listTemplates } from '@/lib/services/documenso-client';
|
||||
*
|
||||
* Lists every Documenso template visible to the configured API key
|
||||
* for the calling port. Drives the "Documenso-first templates" admin
|
||||
* picker (R62) — reps see real template names instead of having to
|
||||
* picker (R62) - reps see real template names instead of having to
|
||||
* type numeric IDs.
|
||||
*
|
||||
* Gated on `admin.manage_settings` since the data exposed is essentially
|
||||
|
||||
@@ -76,7 +76,7 @@ export const PUT = withAuth(
|
||||
userAgent: ctx.userAgent,
|
||||
};
|
||||
if (body.subject === null || body.subject === '') {
|
||||
// Clear the override (and only at the per-port level — never touch global).
|
||||
// Clear the override (and only at the per-port level - never touch global).
|
||||
await deleteSetting(settingKey, ctx.portId, meta);
|
||||
} else {
|
||||
await upsertSetting(settingKey, body.subject, ctx.portId, meta);
|
||||
|
||||
@@ -16,14 +16,14 @@ import { updateSalesEmailConfigSchema } from '@/lib/validators/sales-email-confi
|
||||
* GET /api/v1/admin/email/sales-config
|
||||
*
|
||||
* Returns the redacted view of the sales-email config. Per §14.10
|
||||
* reps can't see the decrypted password — the response only carries
|
||||
* reps can't see the decrypted password - the response only carries
|
||||
* `*PassIsSet` boolean markers via `redactSalesConfigForResponse`.
|
||||
*
|
||||
* Today this endpoint is admin-only because it's consumed only by the
|
||||
* admin UI panel (`src/components/admin/sales-email-config-card.tsx`).
|
||||
* A future rep-facing surface that needs the from-address or body
|
||||
* templates can split into a separate `/email/sales-config/preview`
|
||||
* endpoint scoped to `email.view` — keeping the admin endpoint locked
|
||||
* endpoint scoped to `email.view` - keeping the admin endpoint locked
|
||||
* to `manage_settings` avoids accidentally widening secret-adjacent
|
||||
* surfaces (e.g. the SMTP host name itself can be a leak vector).
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ const bodySchema = z.object({
|
||||
* Sends a small text/HTML message to either the body-supplied `to` or
|
||||
* (default) the admin's own email so they get the verification in their
|
||||
* inbox. Returns { ok: true } on success or { ok: false, error } on
|
||||
* failure — the admin UI rates accordingly.
|
||||
* failure - the admin UI rates accordingly.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
@@ -28,13 +28,13 @@ export const POST = withAuth(
|
||||
const recipient = body.to ?? ctx.user.email;
|
||||
if (!recipient) {
|
||||
return NextResponse.json(
|
||||
{ data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } },
|
||||
{ data: { ok: false, error: 'No recipient resolved - sign-in email is empty' } },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`;
|
||||
const subject = `Port Nimara CRM - SMTP test (${new Date().toLocaleTimeString()})`;
|
||||
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
|
||||
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
|
||||
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
|
||||
|
||||
@@ -21,7 +21,7 @@ const testSendSchema = z.object({
|
||||
* - The branding one exercises the rendering pipeline + logo bytes.
|
||||
*
|
||||
* Surface SMTP errors to the caller directly (auth failure, ENOTFOUND,
|
||||
* connection refused) — the whole point of the test is to see them
|
||||
* connection refused) - the whole point of the test is to see them
|
||||
* inline in the admin UI.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
@@ -30,7 +30,7 @@ export const POST = withAuth(
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
const { recipient } = await parseBody(req, testSendSchema);
|
||||
|
||||
const subject = 'CRM SMTP test — connection verified';
|
||||
const subject = 'CRM SMTP test - connection verified';
|
||||
const html = `
|
||||
<div style="font-family:system-ui,-apple-system,sans-serif;font-size:14px;color:#1e293b;padding:24px;line-height:1.5;">
|
||||
<h1 style="font-size:18px;margin:0 0 12px;">SMTP test</h1>
|
||||
@@ -39,11 +39,11 @@ export const POST = withAuth(
|
||||
are reaching ${recipient}.
|
||||
</p>
|
||||
<p style="margin:0;color:#64748b;font-size:13px;">
|
||||
Sent from /admin/email — Port Nimara CRM
|
||||
Sent from /admin/email - Port Nimara CRM
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email — Port Nimara CRM`;
|
||||
const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email - Port Nimara CRM`;
|
||||
|
||||
const info = await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
|
||||
logger.info(
|
||||
|
||||
100
src/app/api/v1/admin/email/test-template/route.ts
Normal file
100
src/app/api/v1/admin/email/test-template/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { findTestTemplate, TEST_TEMPLATES } from '@/lib/email/test-registry';
|
||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
const bodySchema = z.object({
|
||||
templateId: z.string().min(1),
|
||||
recipient: z.string().email(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET - return the test-template registry (id + label + description)
|
||||
* so the admin UI dropdown can render without duplicating the catalog
|
||||
* client-side.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async () => {
|
||||
try {
|
||||
return NextResponse.json({
|
||||
data: TEST_TEMPLATES.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST - render the chosen template with realistic sample fixtures and
|
||||
* fire it through the configured SMTP transport. Used by admins to
|
||||
* preview each transactional template against a designated address
|
||||
* without triggering the real upstream flow.
|
||||
*
|
||||
* Permission: `admin.manage_settings` - same gate as the existing
|
||||
* SMTP test-send (the port's real From / SMTP credentials are used).
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
const template = findTestTemplate(body.templateId);
|
||||
if (!template) {
|
||||
throw new ValidationError(`Unknown templateId: ${body.templateId}`);
|
||||
}
|
||||
|
||||
// Resolve port branding context so the rendered email actually
|
||||
// matches the admin's port (header logo, accent colour) instead of
|
||||
// falling through to defaults.
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, ctx.portId) });
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
|
||||
// No publicUrl column on `ports` yet - synthesise a plausible URL
|
||||
// from the slug so the sample renders with a "real-looking" base.
|
||||
const portUrl = `https://${port.slug}.example`;
|
||||
const rendered = await template.render({
|
||||
recipientName: 'Sample Recipient',
|
||||
recipientEmail: body.recipient,
|
||||
portName: port.name,
|
||||
portUrl,
|
||||
});
|
||||
|
||||
// Subject prefix makes it visually unambiguous in the recipient's
|
||||
// inbox that this is a test - important because some of the
|
||||
// templates (signing reminder, etc.) would otherwise look
|
||||
// identical to a real production send.
|
||||
const taggedSubject = `[TEST · ${template.label}] ${rendered.subject}`;
|
||||
|
||||
const info = await sendEmail(
|
||||
body.recipient,
|
||||
taggedSubject,
|
||||
rendered.html,
|
||||
undefined,
|
||||
rendered.text,
|
||||
ctx.portId,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipient: body.recipient,
|
||||
subject: taggedSubject,
|
||||
messageId: info.messageId ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -17,8 +17,8 @@ import { logger } from '@/lib/logger';
|
||||
* get sent there from outbound emails.
|
||||
*
|
||||
* Two checks:
|
||||
* 1. Bare host returns 2xx — the site is up.
|
||||
* 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not
|
||||
* 1. Bare host returns 2xx - the site is up.
|
||||
* 2. `/sign/health` (or `/`) returns 2xx within 5s - soft probe; not
|
||||
* every marketing site exposes /sign/health, so we degrade to a
|
||||
* root probe when the dedicated path 404s.
|
||||
*/
|
||||
@@ -60,7 +60,7 @@ export const POST = withAuth(
|
||||
}
|
||||
};
|
||||
|
||||
// Try root first — it's the most universal signal of "the site is
|
||||
// Try root first - it's the most universal signal of "the site is
|
||||
// up." Then probe /sign/success which the post-signing redirect
|
||||
// typically points to, so admins can also catch a stale path.
|
||||
await probe('/');
|
||||
|
||||
@@ -24,7 +24,7 @@ export const GET = withAuth(
|
||||
if (!event) throw new NotFoundError('Error event');
|
||||
|
||||
// Tenant scoping. A port_id of null on the row means the error
|
||||
// fired pre-port-resolve (login page, public form, etc.) — those
|
||||
// fired pre-port-resolve (login page, public form, etc.) - those
|
||||
// are visible to super admins only.
|
||||
if (!ctx.isSuperAdmin) {
|
||||
if (!event.portId || event.portId !== ctx.portId) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const GET = withAuth(
|
||||
}),
|
||||
);
|
||||
|
||||
// Mutations on global roles are super-admin-only — see route.ts header.
|
||||
// Mutations on global roles are super-admin-only - see route.ts header.
|
||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'roles.update');
|
||||
|
||||
@@ -18,7 +18,7 @@ export const GET = withAuth(
|
||||
);
|
||||
|
||||
// Roles are global (no port_id) and assignments span every port via
|
||||
// userPortRoles, so creation must be super-admin-only — a per-port admin
|
||||
// userPortRoles, so creation must be super-admin-only - a per-port admin
|
||||
// holding admin.manage_users must never be able to mint a role that lives
|
||||
// in another tenant.
|
||||
export const POST = withAuth(async (req, ctx) => {
|
||||
|
||||
@@ -14,11 +14,11 @@ import { getSetting } from '@/lib/settings/resolver';
|
||||
* form so the operator can verify what they saved earlier.
|
||||
*
|
||||
* Gated on `admin.manage_settings` (the same permission required to write
|
||||
* the value — so this never widens an existing trust boundary). Every
|
||||
* the value - so this never widens an existing trust boundary). Every
|
||||
* reveal is audit-logged with the request id so a super-admin can trace
|
||||
* who looked at what and when.
|
||||
*
|
||||
* Refuses to reveal values resolved from `env` or `default` — those would
|
||||
* Refuses to reveal values resolved from `env` or `default` - those would
|
||||
* leak server-process secrets via the API.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
|
||||
@@ -12,11 +12,11 @@ import { resolveForAdminAPI } from '@/lib/settings/resolver';
|
||||
* Returns the resolved value + source (port/global/env/default) for every
|
||||
* requested registry entry. Drives both the registry-driven admin form
|
||||
* (sections param) and the onboarding-checklist auto-detection (keys
|
||||
* param) — both need port→global→env→default resolution rather than the
|
||||
* param) - both need port→global→env→default resolution rather than the
|
||||
* raw `/admin/settings` rows (which only show DB writes).
|
||||
*
|
||||
* Either parameter is supported; if both are present the sets union.
|
||||
* Sensitive fields surface `isSet` only — never the decrypted value.
|
||||
* Sensitive fields surface `isSet` only - never the decrypted value.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
@@ -55,7 +55,7 @@ export const GET = withAuth(
|
||||
|
||||
// Return the entry metadata so the client can render labels/types
|
||||
// without bundling the registry into the client JS. Strip the
|
||||
// `validator` + `transform` function references — they're not
|
||||
// `validator` + `transform` function references - they're not
|
||||
// JSON-serializable.
|
||||
const entriesForClient = entries.map((e) => ({
|
||||
key: e.key,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Admin storage status + connection test. Super-admin only.
|
||||
*
|
||||
* GET /api/v1/admin/storage — current backend + capacity stats
|
||||
* POST /api/v1/admin/storage/test — exercise list/put/get/delete on s3
|
||||
* GET /api/v1/admin/storage - current backend + capacity stats
|
||||
* POST /api/v1/admin/storage/test - exercise list/put/get/delete on s3
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* PUT accepts a Partial<RolePermissions> map (use null at a leaf to clear an
|
||||
* override) and upserts it onto user_permission_overrides for (userId, portId).
|
||||
* Permission `admin.manage_users` is required — same gate as the user-edit
|
||||
* Permission `admin.manage_users` is required - same gate as the user-edit
|
||||
* drawer that hosts the matrix.
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
@@ -85,7 +85,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
||||
};
|
||||
|
||||
const updateOverridesSchema = z.object({
|
||||
/** Partial<RolePermissions> — passthrough JSON. Validated structurally
|
||||
/** Partial<RolePermissions> - passthrough JSON. Validated structurally
|
||||
* by limiting depth + leaf type below. */
|
||||
overrides: z.record(z.string(), z.record(z.string(), z.boolean())).default({}),
|
||||
});
|
||||
@@ -121,7 +121,7 @@ export const GET = withAuth(
|
||||
),
|
||||
});
|
||||
if (baseline && portOverride?.permissionOverrides) {
|
||||
// Cheap structural merge — same shape as helpers.ts's deepMerge.
|
||||
// Cheap structural merge - same shape as helpers.ts's deepMerge.
|
||||
baseline = mergePerms(baseline, portOverride.permissionOverrides);
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ export const PUT = withAuth(
|
||||
}
|
||||
|
||||
// Reject overrides for users that aren't actually assigned to this
|
||||
// port — prevents cross-tenant pollution where an admin in port A
|
||||
// port - prevents cross-tenant pollution where an admin in port A
|
||||
// writes a row keyed on (userIdFromPortB, portA). The withAuth
|
||||
// resolver scopes lookups to the caller's port so the row would
|
||||
// never apply, but it still consumes a unique slot and confuses
|
||||
@@ -183,7 +183,7 @@ export const PUT = withAuth(
|
||||
// honour.
|
||||
// CALLER-SUPERSET (authz-auditor CRITICAL): an admin with only
|
||||
// `admin.manage_users` previously could grant another user any
|
||||
// permission leaf — including ones they don't hold themselves
|
||||
// permission leaf - including ones they don't hold themselves
|
||||
// (e.g. `permanently_delete_clients`, `system_backup`). Require
|
||||
// every `true` write to be a leaf the caller already has.
|
||||
// Super-admins bypass (they hold all leaves by definition).
|
||||
|
||||
@@ -14,7 +14,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
* slot). Returns only the fields needed to render an option: id, email,
|
||||
* name. Excludes deactivated users.
|
||||
*
|
||||
* Gated on `admin.manage_settings` — anyone editing per-port admin
|
||||
* Gated on `admin.manage_settings` - anyone editing per-port admin
|
||||
* settings can already see all the configured Documenso recipient
|
||||
* email/name values, so revealing the user roster to them doesn't
|
||||
* widen the trust boundary. Tighter than the full `admin/users` GET
|
||||
|
||||
@@ -9,7 +9,7 @@ import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { requestDraftSchema } from '@/lib/validators/ai';
|
||||
import { CodedError, errorResponse } from '@/lib/errors';
|
||||
|
||||
// Gated on `email.send` — the draft endpoint spends OpenAI tokens and
|
||||
// Gated on `email.send` - the draft endpoint spends OpenAI tokens and
|
||||
// renders client/interest-scoped content; only roles permitted to send
|
||||
// emails should be able to mint drafts (auditor-A3 §7).
|
||||
export const POST = withAuth(
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
// signals. Gated on admin.view_audit_log - same permission the audit log
|
||||
// page uses.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { listDealDocumentsForBerth } from '@/lib/services/documents.service';
|
||||
|
||||
/**
|
||||
* GET /api/v1/berths/[id]/interest-documents (renamed from
|
||||
* `/deal-documents` in the 2026-05-14 terminology sweep — canonical
|
||||
* `/deal-documents` in the 2026-05-14 terminology sweep - canonical
|
||||
* noun is "interest").
|
||||
*
|
||||
* Lists documents attached to interests currently linked to this berth.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getStorageBackend } from '@/lib/storage';
|
||||
|
||||
const postBodySchema = z.object({
|
||||
fileName: z.string().min(1).max(255),
|
||||
/** Size hint in bytes — used to early-reject oversized uploads before we
|
||||
/** Size hint in bytes - used to early-reject oversized uploads before we
|
||||
* burn a presigned URL. */
|
||||
sizeBytes: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
||||
// and pdf-upload-url tenant-scopes the berth lookup. Without this regex,
|
||||
// a rep with berths.edit could ship the storage key of a foreign-port
|
||||
// PDF (signed EOI, brochure blob, another port's berth) and have the
|
||||
// service repoint THIS berth's currentPdfVersionId at it — subsequent
|
||||
// service repoint THIS berth's currentPdfVersionId at it - subsequent
|
||||
// pdf-download serves those bytes under the rep's own permission gate.
|
||||
const STORAGE_KEY_RE =
|
||||
/^berths\/[A-Za-z0-9_-]+\/uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { bulkAddBerthsSchema } from '@/lib/validators/berths';
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
// F13: aligned with the seed-permissions scope (`berths.import`).
|
||||
// The previous `berths.create` was a phantom key — not in the role
|
||||
// The previous `berths.create` was a phantom key - not in the role
|
||||
// matrix, so non-super-admins silently failed permission resolution.
|
||||
withPermission('berths', 'import', async (req, ctx) => {
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
* Gated by `berths.update_prices`. Returns counts so the UI can present
|
||||
* "Updated N · Unchanged M · Missing K" feedback.
|
||||
*
|
||||
* Audit: one `audit_log` row per actually-updated berth (idempotent —
|
||||
* Audit: one `audit_log` row per actually-updated berth (idempotent -
|
||||
* berths whose new price matches the existing value are skipped and
|
||||
* counted as `unchanged`).
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Synchronous bulk endpoint for the berths list — mirrors the
|
||||
* Synchronous bulk endpoint for the berths list - mirrors the
|
||||
* /api/v1/interests/bulk shape so the rep-facing UX is consistent.
|
||||
*
|
||||
* Per-row loop with a 500-id cap. Bigger jobs belong on the BullMQ
|
||||
@@ -58,7 +58,7 @@ interface RowResult {
|
||||
}
|
||||
|
||||
// Berths share a single `edit` permission for non-price mutations (no
|
||||
// separate `archive` perm today — sales-manager + super-admin own all
|
||||
// separate `archive` perm today - sales-manager + super-admin own all
|
||||
// edit paths).
|
||||
const PERMISSION_BY_ACTION: Record<
|
||||
z.infer<typeof bulkSchema>['action'],
|
||||
|
||||
@@ -25,7 +25,7 @@ const checkSchema = z.object({
|
||||
* surfacing the constraint violation at submit time.
|
||||
*
|
||||
* Format validation mirrors the CLAUDE.md canonical (`^[A-Z]+\d+$`).
|
||||
* Archived berths are excluded — bulk-add re-using a previously-archived
|
||||
* Archived berths are excluded - bulk-add re-using a previously-archived
|
||||
* mooring number is a legitimate flow.
|
||||
*
|
||||
* Permission gating: `berths.import` (same scope as bulk-add itself).
|
||||
|
||||
@@ -6,7 +6,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
/**
|
||||
* GET /api/v1/bootstrap/status
|
||||
*
|
||||
* PUBLIC — no auth required. Used by the /setup and /login pages to
|
||||
* PUBLIC - no auth required. Used by the /setup and /login pages to
|
||||
* decide which screen to show on first visit. Returns only a single
|
||||
* boolean to keep the response small and minimize info leakage.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ const bodySchema = z.object({
|
||||
/**
|
||||
* POST /api/v1/bootstrap/super-admin
|
||||
*
|
||||
* PUBLIC — no auth required, but bound by a single-shot precondition:
|
||||
* PUBLIC - no auth required, but bound by a single-shot precondition:
|
||||
* refuses to run when a super-admin already exists. Idempotently safe:
|
||||
* the service double-checks the precondition before insert, so two
|
||||
* racing first-run requests can't both create accounts.
|
||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
|
||||
// atomically before the insert.
|
||||
if (await hasAnySuperAdmin()) {
|
||||
throw new ConflictError(
|
||||
'A super-administrator account already exists — first-run setup is closed.',
|
||||
'A super-administrator account already exists - first-run setup is closed.',
|
||||
);
|
||||
}
|
||||
const body = await parseBody(req, bodySchema);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
import { promoteContactToPrimary } from '@/lib/services/clients.service';
|
||||
|
||||
/**
|
||||
* Phase 3d — promote a non-primary `client_contacts` row to primary,
|
||||
* Phase 3d - promote a non-primary `client_contacts` row to primary,
|
||||
* demoting the prior primary for the same channel inside a single
|
||||
* transaction. Surfaces from the "[EOI] Set as primary" action on the
|
||||
* client detail panel, and from the EOI dialog's "Set as default for
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createAuditLog } from '@/lib/audit';
|
||||
* Returns a fresh signed URL for an existing GDPR export. Staff use this
|
||||
* from the admin UI; the email path embeds its own signed URL.
|
||||
*
|
||||
* Every call writes a `view` audit row at 'warning' severity — GDPR
|
||||
* Every call writes a `view` audit row at 'warning' severity - GDPR
|
||||
* exports contain the entire personal data of a client and a fresh
|
||||
* presigned URL would let the operator download it; we want a clear
|
||||
* trail of who pulled what when.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
* `clients.delete` (the standard archive permission) is enforced by the
|
||||
* route wrapper; the service additionally requires the client to be
|
||||
* archived. The dedicated `admin.permanently_delete_clients` flag is
|
||||
* checked by the partner /hard-delete route — see route comment there.
|
||||
* checked by the partner /hard-delete route - see route comment there.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission(
|
||||
|
||||
@@ -14,7 +14,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
*
|
||||
* Backwards-compat: clients archived before the smart-archive feature
|
||||
* have no archive_metadata. The dossier returns empty arrays in that
|
||||
* case, and a POST with no body simply un-archives them — same effect
|
||||
* case, and a POST with no body simply un-archives them - same effect
|
||||
* as the old endpoint.
|
||||
*/
|
||||
const restoreSchema = z.object({
|
||||
@@ -32,7 +32,7 @@ export const POST = withAuth(
|
||||
try {
|
||||
body = await parseBody(req, restoreSchema);
|
||||
} catch {
|
||||
// Empty / non-JSON body — defaults are fine.
|
||||
// Empty / non-JSON body - defaults are fine.
|
||||
}
|
||||
|
||||
const result = await restoreClientWithSelections({
|
||||
|
||||
@@ -50,7 +50,7 @@ export const POST = withAuth(
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Generic blocker text — never include the inner error so an
|
||||
// Generic blocker text - never include the inner error so an
|
||||
// attacker can't distinguish "not found" from "in another port"
|
||||
// by enumerating UUIDs (audit R2-M9). The operator already
|
||||
// selected these IDs so they don't need to know the cause.
|
||||
@@ -59,7 +59,7 @@ export const POST = withAuth(
|
||||
fullName: '(unknown)',
|
||||
stakeLevel: 'low',
|
||||
highStakesStage: null,
|
||||
blockers: ['Could not load dossier — client may have been removed'],
|
||||
blockers: ['Could not load dossier - client may have been removed'],
|
||||
summary: { berths: 0, yachts: 0, reservations: 0, signedDocs: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const POST = withAuth(async (req, ctx) => {
|
||||
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
|
||||
// Pick the berth's first linked interest from the dossier
|
||||
// (authoritative interest_berths join). Berths with no linked
|
||||
// interest for this client are dropped — emitting an empty
|
||||
// interest for this client are dropped - emitting an empty
|
||||
// interestId causes the delete to silently match zero rows
|
||||
// (audit R2-H3).
|
||||
const berthDecisions = dossier.berths
|
||||
|
||||
@@ -143,7 +143,7 @@ export async function getMatchCandidatesHandler(
|
||||
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Build a lookup from the original pool for archived flag — the dedup
|
||||
// Build a lookup from the original pool for archived flag - the dedup
|
||||
// candidate type intentionally doesn't carry it, but the suggestion card
|
||||
// needs to differentiate "use this live client" from "restore this
|
||||
// archived client". Without this the UX swallows soft-deleted dupes.
|
||||
|
||||
@@ -8,7 +8,7 @@ import { parseRangeSlug, rangeToBounds } from '@/lib/analytics/range';
|
||||
* GET /api/v1/dashboard/forecast
|
||||
* GET /api/v1/dashboard/forecast?range=7d|30d|90d|today|custom-<from>-<to>
|
||||
*
|
||||
* Same range semantics as /kpis — the weighted forecast scopes to
|
||||
* Same range semantics as /kpis - the weighted forecast scopes to
|
||||
* interests whose createdAt falls inside the window when range is set,
|
||||
* or all-time when not.
|
||||
*/
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
|
||||
/**
|
||||
* PATCH supports either { name } (rename) or { parentId } (move).
|
||||
* Refuses both in the same body — keeps the audit log clean
|
||||
* Refuses both in the same body - keeps the audit log clean
|
||||
* (one operation per call) and prevents the rep from accidentally
|
||||
* doing two unrelated changes in one click.
|
||||
*/
|
||||
// `.strict()` on each branch so a body with BOTH name and parentId is
|
||||
// rejected by both members and the union produces a 400 — without it,
|
||||
// rejected by both members and the union produces a 400 - without it,
|
||||
// z.union silently picks the first match and drops the other key,
|
||||
// which would let a rename request silently swallow a move attempt.
|
||||
const patchBodySchema = z.union([renameFolderSchema.strict(), moveFolderSchema.strict()]);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { listTree, createFolder } from '@/lib/services/document-folders.service'
|
||||
*
|
||||
* Returns the entire folder tree for the caller's port. Roots come
|
||||
* back at the top level with `children` nested. Cached on the client
|
||||
* via TanStack — folders change rarely; the manager mutations
|
||||
* via TanStack - folders change rarely; the manager mutations
|
||||
* invalidate the query.
|
||||
*
|
||||
* Permission: documents.view (read-only; everyone in the port can
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getStorageBackend } from '@/lib/storage';
|
||||
import { detectFields } from '@/lib/services/document-field-detector';
|
||||
|
||||
/**
|
||||
* Phase 4 — Auto-detect signature/date/initials/name/email anchors in the
|
||||
* Phase 4 - Auto-detect signature/date/initials/name/email anchors in the
|
||||
* template's current source PDF and return suggested field placements.
|
||||
*
|
||||
* The detector (`src/lib/services/document-field-detector.ts`) scans each
|
||||
@@ -18,7 +18,7 @@ import { detectFields } from '@/lib/services/document-field-detector';
|
||||
* coords (0..100 of page dimensions), which the editor converts to its
|
||||
* own 0..1 marker coords before adding to the field map.
|
||||
*
|
||||
* Permission: `admin.manage_settings` — same gate as the editor itself.
|
||||
* Permission: `admin.manage_settings` - same gate as the editor itself.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
||||
@@ -29,7 +29,7 @@ export const POST = withAuth(
|
||||
if (!template) throw new NotFoundError('Template');
|
||||
if (!template.sourceFileId) {
|
||||
throw new ValidationError(
|
||||
'Template has no source PDF — upload one first via the Replace PDF button',
|
||||
'Template has no source PDF - upload one first via the Replace PDF button',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const POST = withAuth(
|
||||
throw new NotFoundError('Source PDF file row missing');
|
||||
}
|
||||
|
||||
// Read the PDF blob from storage. Buffer the whole stream — the
|
||||
// Read the PDF blob from storage. Buffer the whole stream - the
|
||||
// detector needs a contiguous Buffer for pdfjs-dist, and template
|
||||
// source PDFs are capped at 10MB by the source-pdf upload route.
|
||||
const backend = await getStorageBackend();
|
||||
|
||||
@@ -18,13 +18,13 @@ const previewBodySchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 7.2 — live preview endpoint for the PDF editor.
|
||||
* Phase 7.2 - live preview endpoint for the PDF editor.
|
||||
*
|
||||
* Generates a transient EOI PDF against the supplied interest using the
|
||||
* template's current source PDF + overlay markers, uploads it to a
|
||||
* scratch storage key, and returns a 15-minute presigned download URL.
|
||||
*
|
||||
* The blob is intentionally not linked to a `files` row — preview PDFs
|
||||
* The blob is intentionally not linked to a `files` row - preview PDFs
|
||||
* are throwaway. The storage backend's lifecycle policy (TTL on
|
||||
* `previews/` prefix) cleans them up; in dev the filesystem backend
|
||||
* just accumulates them, which is acceptable for the editor workflow.
|
||||
@@ -39,7 +39,7 @@ export const POST = withAuth(
|
||||
});
|
||||
if (!template) throw new NotFoundError('Template');
|
||||
if (template.templateType !== 'eoi') {
|
||||
// Live preview is currently EOI-only — that's where the
|
||||
// Live preview is currently EOI-only - that's where the
|
||||
// editor's overlay-positions flow into rendering. Other
|
||||
// template types are deferred (no in-app fill yet).
|
||||
throw new ValidationError(
|
||||
|
||||
@@ -15,7 +15,7 @@ const MAX_PDF_BYTES = 10 * 1024 * 1024;
|
||||
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // "%PDF-"
|
||||
|
||||
/**
|
||||
* Phase 7.2 — replace the template's source PDF while preserving the
|
||||
* Phase 7.2 - replace the template's source PDF while preserving the
|
||||
* field map. The existing `overlay_positions` is kept exactly as-is;
|
||||
* the client warns when the new page count truncates the previous set
|
||||
* (markers on now-orphaned pages are invisible at render time).
|
||||
|
||||
@@ -10,6 +10,15 @@ const cancelBodySchema = z
|
||||
.object({
|
||||
reason: z.string().max(2000).optional().nullable(),
|
||||
notifyRecipients: z.array(z.string().uuid()).max(20).optional(),
|
||||
/**
|
||||
* Whether to also DELETE the document from Documenso. `delete` (the
|
||||
* default) frees the upstream envelope slot - useful for unclogging
|
||||
* the Documenso log when a draft was abandoned. `keep_remote`
|
||||
* leaves the envelope intact for audit purposes; only the local
|
||||
* row is marked `cancelled`. Audit-trail copy on the cancelled-doc
|
||||
* badge changes accordingly.
|
||||
*/
|
||||
cancelMode: z.enum(['delete', 'keep_remote']).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
@@ -17,7 +26,7 @@ const cancelBodySchema = z
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
// Body is optional — legacy callers POST with `{}`. parseBody returns
|
||||
// Body is optional - legacy callers POST with `{}`. parseBody returns
|
||||
// null when the request has no body; default to empty options.
|
||||
let body: z.infer<typeof cancelBodySchema> = undefined;
|
||||
try {
|
||||
@@ -37,6 +46,7 @@ export const POST = withAuth(
|
||||
{
|
||||
reason: body?.reason ?? null,
|
||||
notifyRecipients: body?.notifyRecipients ?? [],
|
||||
cancelMode: body?.cancelMode ?? 'delete',
|
||||
},
|
||||
);
|
||||
return NextResponse.json({ data: doc });
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Lookup is keyed off the doc id; the slug embeds the current folder path +
|
||||
* filename so a forwarded link reads like `Deals 2026/Q1/contract.pdf` even
|
||||
* though the underlying storage key is a UUID. The slug is rebuilt from
|
||||
* current state and compared with the supplied path — a stale or
|
||||
* current state and compared with the supplied path - a stale or
|
||||
* hand-edited URL 404s rather than silently serving the wrong file.
|
||||
*/
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createAuditLog } from '@/lib/audit';
|
||||
|
||||
/**
|
||||
* Per-document move endpoint. Moving a single document is a deliberate
|
||||
* user action so we DO bump `updated_at` here — different semantics from
|
||||
* user action so we DO bump `updated_at` here - different semantics from
|
||||
* the bulk soft-rescue in `deleteFolderSoftRescue` where the timestamp
|
||||
* stays put because reps did not act on the individual documents.
|
||||
*
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
const bodySchema = z.object({
|
||||
/** Optional — defaults to the next pending signer in signing-order. */
|
||||
/** Optional - defaults to the next pending signer in signing-order. */
|
||||
recipientId: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ export const POST = withAuth(
|
||||
|
||||
// Self-heal flow when target.signingUrl is null. Two scenarios:
|
||||
// 1. Envelope was created before the auto-distribute fix shipped
|
||||
// — never distributed, so we must call /envelope/distribute
|
||||
// - never distributed, so we must call /envelope/distribute
|
||||
// to mint URLs.
|
||||
// 2. Envelope WAS auto-distributed at generate time, but the
|
||||
// response we got didn't carry signingUrls into our DB row
|
||||
@@ -74,7 +74,7 @@ export const POST = withAuth(
|
||||
// Defensive flow: try `getEnvelope` FIRST (cheap, always works).
|
||||
// If recipients carry signingUrls, persist + skip distribute.
|
||||
// If not, fall through to distribute, but catch 4xx so we don't
|
||||
// surface a confusing "Documenso upstream error" to the rep —
|
||||
// surface a confusing "Documenso upstream error" to the rep -
|
||||
// instead we re-fetch via GET one more time and accept whatever
|
||||
// URLs the envelope has.
|
||||
if (!target.signingUrl && doc.documensoId) {
|
||||
@@ -116,7 +116,7 @@ export const POST = withAuth(
|
||||
recovered = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through to distribute attempt
|
||||
// ignore - fall through to distribute attempt
|
||||
}
|
||||
|
||||
// Step 2: distribute, only if GET didn't recover URLs.
|
||||
@@ -125,7 +125,7 @@ export const POST = withAuth(
|
||||
const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId);
|
||||
await persistUrlsForDocument(distributed.recipients);
|
||||
} catch {
|
||||
// Probably "already distributed" — last-ditch GET.
|
||||
// Probably "already distributed" - last-ditch GET.
|
||||
try {
|
||||
const fetched = await getDocument(doc.documensoId, ctx.portId);
|
||||
await persistUrlsForDocument(fetched.recipients);
|
||||
@@ -146,7 +146,7 @@ export const POST = withAuth(
|
||||
|
||||
if (!target.signingUrl) {
|
||||
throw new ValidationError(
|
||||
'Signer has no Documenso URL yet — try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
|
||||
'Signer has no Documenso URL yet - try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export const POST = withAuth(
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
signerRole: (target.signerRole as SignerRole) ?? 'client',
|
||||
senderName: docCfg.developerName ?? null,
|
||||
// Phase 6 — surface the per-doc rep-authored note when set so
|
||||
// Phase 6 - surface the per-doc rep-authored note when set so
|
||||
// every cascaded invite and any manual resend show the same
|
||||
// copy. Falls back to the template default when null/empty.
|
||||
customMessage: doc.invitationMessage,
|
||||
|
||||
@@ -6,19 +6,19 @@ import { detectFields } from '@/lib/services/document-field-detector';
|
||||
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
|
||||
|
||||
/**
|
||||
* Phase 4 — Auto-detect anchor scanner endpoint.
|
||||
* Phase 4 - Auto-detect anchor scanner endpoint.
|
||||
*
|
||||
* POST `/api/v1/documents/auto-detect-fields`
|
||||
*
|
||||
* Body: multipart/form-data
|
||||
* - file: the source PDF the rep just uploaded
|
||||
*
|
||||
* Returns: `{ data: { fields: DetectedField[] } }` — seed state for the
|
||||
* Returns: `{ data: { fields: DetectedField[] } }` - seed state for the
|
||||
* drag-drop overlay. Empty array when the PDF has no extractable text
|
||||
* (image-only scan) — the dialog falls back to manual placement
|
||||
* (image-only scan) - the dialog falls back to manual placement
|
||||
* without an error toast.
|
||||
*
|
||||
* Permission: documents.send_for_signing — the only flow that calls
|
||||
* Permission: documents.send_for_signing - the only flow that calls
|
||||
* this endpoint is the upload-for-signing dialog, which already
|
||||
* requires that bit. Reusing it here means a custom role with the
|
||||
* upload bit but no send bit can't dry-run the detector to pull
|
||||
|
||||
@@ -10,11 +10,11 @@ import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync
|
||||
*
|
||||
* Returns the per-port developer + approver defaults the
|
||||
* UploadForSigningDialog uses to prefill the recipient configurator.
|
||||
* No secrets are exposed — just the display name, email, and the
|
||||
* No secrets are exposed - just the display name, email, and the
|
||||
* sendMode flag so the UI can show the right CTA copy ("Send now" vs
|
||||
* "Save draft, send manually").
|
||||
*
|
||||
* Permission: documents.send_for_signing — the only caller is the
|
||||
* Permission: documents.send_for_signing - the only caller is the
|
||||
* upload-for-signing dialog which already requires this permission to
|
||||
* complete the flow.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ export const GET = withAuth(
|
||||
|
||||
// Signing order resolution chain (highest → lowest priority):
|
||||
// 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder`
|
||||
// — populated by the admin "Sync from Documenso" button and
|
||||
// - populated by the admin "Sync from Documenso" button and
|
||||
// represents the live template's bound order. On v2 this is the
|
||||
// authoritative value because `/template/use` doesn't accept a
|
||||
// per-call override.
|
||||
@@ -53,7 +53,7 @@ export const GET = withAuth(
|
||||
signingOrder,
|
||||
// Surface where the value came from so the UI tooltip can be
|
||||
// honest about the source. Helps reps debug "I changed it in
|
||||
// Documenso but the CRM still says X" — they need to re-run
|
||||
// Documenso but the CRM still says X" - they need to re-run
|
||||
// Sync to pull the change.
|
||||
signingOrderSource: syncReport?.templateMeta?.signingOrder
|
||||
? 'template'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { exportExpensePdfSchema } from '@/lib/validators/expenses';
|
||||
/**
|
||||
* POST /api/v1/expenses/export/pdf
|
||||
*
|
||||
* Streams the expense report PDF directly to the client — body bytes
|
||||
* Streams the expense report PDF directly to the client - body bytes
|
||||
* leave the process as pdfkit writes them, so the route is safe for
|
||||
* hundreds of expenses with full-resolution receipt images. See
|
||||
* `expense-pdf.service.ts` for the memory-budget design.
|
||||
@@ -53,7 +53,7 @@ export const POST = withAuth(
|
||||
// Forward the request abort signal so the streaming PDF builder
|
||||
// stops fetching/resizing receipts the moment the client disconnects
|
||||
// (otherwise an aborted 1000-receipt export keeps the worker busy
|
||||
// for minutes after the user navigated away — see audit finding 2).
|
||||
// for minutes after the user navigated away - see audit finding 2).
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const POST = withAuth(
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
if (!file) throw new ValidationError('A file is required');
|
||||
// Hard 10 MB cap — without this any authenticated rep could grief
|
||||
// Hard 10 MB cap - without this any authenticated rep could grief
|
||||
// their own port's AI budget by sending arbitrarily large images
|
||||
// and burning OCR tokens (auditor-E3 §28).
|
||||
const MAX_OCR_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { listTripLabels } from '@/lib/services/expenses';
|
||||
* "Palm Beach 2026" vs " palm beach 2026 " split across two groups in
|
||||
* the PDF export.
|
||||
*
|
||||
* Permission: `expenses.view` — same gate as the list endpoint.
|
||||
* Permission: `expenses.view` - same gate as the list endpoint.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('expenses', 'view', async (req, ctx) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const POST = withAuth(
|
||||
// Zero-byte marker through the active storage backend. S3 stores it
|
||||
// as an empty object; the filesystem backend currently materializes
|
||||
// it as an empty file (a future refactor should move folder
|
||||
// bookkeeping to a DB-backed virtual-folder table — see
|
||||
// bookkeeping to a DB-backed virtual-folder table - see
|
||||
// docs/audit-comprehensive-2026-05-05.md HIGH §3 follow-up).
|
||||
await (
|
||||
await getStorageBackend()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user