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:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -17,7 +17,7 @@ import { useAuthBranding } from '@/components/shared/auth-branding-provider';
// `identifier` accepts either an email address or a username (330 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;

View File

@@ -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.');

View File

@@ -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 '';

View File

@@ -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);

View File

@@ -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>

View 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&apos;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>
);
}

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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 />}
/>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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) {

View File

@@ -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`);
}

View File

@@ -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;

View File

@@ -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`);
}

View File

@@ -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. */}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},

View File

@@ -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({

View File

@@ -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.

View File

@@ -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,

View File

@@ -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.

View File

@@ -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}

View File

@@ -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) => ({

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 });

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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');

View File

@@ -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;

View File

@@ -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,

View File

@@ -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()

View File

@@ -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',

View File

@@ -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;

View File

@@ -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.
*/

View File

@@ -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,

View File

@@ -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).
*/

View File

@@ -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 {

View File

@@ -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).

View File

@@ -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)) {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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 {

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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);

View File

@@ -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).
*/

View File

@@ -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);

View File

@@ -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(

View 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);
}
}),
);

View File

@@ -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('/');

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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) => {

View File

@@ -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(

View File

@@ -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,

View File

@@ -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';

View File

@@ -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).

View File

@@ -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

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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.

View File

@@ -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(),
});

View File

@@ -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}_/;

View File

@@ -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 {

View File

@@ -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`).
*/

View File

@@ -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'],

View File

@@ -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).

View File

@@ -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.
*/

View File

@@ -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);

View File

@@ -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

View File

@@ -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.

View File

@@ -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(

View File

@@ -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({

View File

@@ -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 },
});
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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.
*/

View File

@@ -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()]);

View File

@@ -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

View File

@@ -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();

View File

@@ -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(

View File

@@ -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).

View File

@@ -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 });

View File

@@ -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.
*/

View 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.
*

View File

@@ -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,

View File

@@ -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

View File

@@ -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'

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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