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

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

View File

@@ -39,7 +39,7 @@ const patchBerthSchema = z
async function loadScopedRow(interestId: string, berthId: string, portId: string) {
// Verify interest port-scope first so unrelated 404s look identical to a
// truly-missing row (enumeration prevention plan §14.10).
// truly-missing row (enumeration prevention - plan §14.10).
const interest = await db.query.interests.findFirst({
where: eq(interests.id, interestId),
});
@@ -73,7 +73,7 @@ export const patchHandler: RouteHandler = async (req, ctx, params) => {
const { interest } = await loadScopedRow(interestId, berthId, ctx.portId);
// Plan §5.5: the bypass control is only available once the interest's
// primary EOI is signed. Defend the API too never trust the UI to
// primary EOI is signed. Defend the API too - never trust the UI to
// gate this.
if (body.eoiBypassReason !== undefined && interest.eoiStatus !== 'signed') {
throw new ValidationError('EOI bypass requires a signed primary EOI on the interest');

View File

@@ -63,7 +63,7 @@ export const addHandler: RouteHandler = async (req, ctx, params) => {
}
// Tenant scope: berth must belong to this port (never trust a client-
// supplied id to cross port boundaries plan §14.10).
// supplied id to cross port boundaries - plan §14.10).
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, body.berthId), eq(berths.portId, ctx.portId)),
});

View File

@@ -14,7 +14,7 @@ import { buildEoiContext } from '@/lib/services/eoi-context';
* correct) every value before sending the document for signing.
*
* Augments the core context with `available.emails` / `available.phones`
* every non-deleted client_contacts row for the linked client. The
* - every non-deleted client_contacts row for the linked client. The
* dialog renders these as combobox options so the rep can pick a
* secondary contact for this EOI (Phase 3b).
*

View File

@@ -28,7 +28,7 @@ export const GET = withAuth(
export const POST = withAuth(
withPermission('payments', 'record', async (req, ctx, params) => {
try {
// Body's interestId must match the URL param defense-in-depth against
// Body's interestId must match the URL param - defense-in-depth against
// a client that sends one ID in the URL but another in the body.
const body = await parseBody(req, createPaymentSchema);
if (body.interestId !== params.id) {

View File

@@ -7,8 +7,8 @@ import { errorResponse } from '@/lib/errors';
import { recommendBerths } from '@/lib/services/berth-recommender.service';
/**
* POST body mirrors `RecommendBerthsArgs` minus the `interestId` (route
* param) and `portId` (resolved from the auth context never trust a
* POST body - mirrors `RecommendBerthsArgs` minus the `interestId` (route
* param) and `portId` (resolved from the auth context - never trust a
* client-supplied port, plan §14.10).
*/
const recommendBerthsSchema = z

View File

@@ -26,7 +26,7 @@ export const PATCH = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
// A19 / F27: same-stage write returns the sentinel emit 204.
// A19 / F27: same-stage write returns the sentinel - emit 204.
if (interest === STAGE_NOOP) {
return new NextResponse(null, { status: 204 });
}

View File

@@ -19,7 +19,7 @@ import { brandingPrimaryColor, renderShell } from '@/lib/email/shell';
* Generates a one-shot token + emails the client the public form URL.
*/
/**
* GET list past issuances for the interest. Lets the rep see when each
* GET - list past issuances for the interest. Lets the rep see when each
* token was generated + which one is currently active, so they can choose
* Resend (re-email the existing token) over Regenerate (mint a fresh one)
* when the same client is still working through the existing form.
@@ -58,7 +58,7 @@ export const POST = withAuth(
resendTokenId = body.tokenId;
}
} catch {
// No JSON body keep the default.
// No JSON body - keep the default.
}
const result = resendTokenId
@@ -72,13 +72,13 @@ export const POST = withAuth(
// §1.4: prefer the per-port supplemental_form_url (typically the
// marketing site's hosted form) when configured; otherwise fall
// back to the built-in CRM route. Both modes use the same token
// the marketing site forwards the token to the same backend.
// - the marketing site forwards the token to the same backend.
const emailCfg = await getPortEmailConfig(ctx.portId);
const link = emailCfg.supplementalFormUrl
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
// Resend implies "email me again" the rep clicked the action with
// Resend implies "email me again" - the rep clicked the action with
// intent. Force the email path on for resends regardless of the
// `sendEmail` body flag.
const willSendEmail = resendTokenId ? true : shouldSendEmail;
@@ -106,7 +106,7 @@ export const POST = withAuth(
</p>
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 16px;">
Before we draft your Expression of Interest, we need to confirm a few details.
The form below is pre-filled with what we have on file please review, correct
The form below is pre-filled with what we have on file - please review, correct
anything that&apos;s wrong, and add what&apos;s missing.
</p>
<p style="text-align:center;margin:24px 0;">

View File

@@ -7,7 +7,10 @@ import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { auditLogs } from '@/lib/db/schema/system';
import { documents, documentEvents } from '@/lib/db/schema/documents';
import { user } from '@/lib/db/schema/users';
import { user, userProfiles } from '@/lib/db/schema/users';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { clients } from '@/lib/db/schema/clients';
import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = {
@@ -107,6 +110,77 @@ export const GET = withAuth(
return userNameById.get(userId) ?? null;
};
// Collect every UUID that appears in an audit row's newValue under
// a known FK field, then batch-resolve to human labels - berth
// mooring numbers, yacht names, client names, user display names.
// Without this, `Updated primary berth → <uuid>` leaks raw IDs
// into the timeline. Order: scan rows, fetch labels, build maps.
const berthIds = new Set<string>();
const yachtIds = new Set<string>();
const clientFkIds = new Set<string>();
const userFkIds = new Set<string>();
const USER_FK_FIELDS = new Set(['assignedTo', 'ownerId', 'createdBy', 'reassignedTo']);
for (const row of auditRows) {
const nv = row.newValue as Record<string, unknown> | null;
if (!nv) continue;
for (const [key, val] of Object.entries(nv)) {
if (typeof val !== 'string' || val.length < 32) continue;
if (key === 'berthId') berthIds.add(val);
else if (key === 'yachtId') yachtIds.add(val);
else if (key === 'clientId') clientFkIds.add(val);
else if (USER_FK_FIELDS.has(key)) userFkIds.add(val);
}
}
const [berthRows, yachtRows, clientRows, profileRows] = await Promise.all([
berthIds.size > 0
? db
.select({ id: berths.id, mooring: berths.mooringNumber })
.from(berths)
.where(inArray(berths.id, Array.from(berthIds)))
: Promise.resolve([] as Array<{ id: string; mooring: string }>),
yachtIds.size > 0
? db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(inArray(yachts.id, Array.from(yachtIds)))
: Promise.resolve([] as Array<{ id: string; name: string }>),
clientFkIds.size > 0
? db
.select({ id: clients.id, name: clients.fullName })
.from(clients)
.where(inArray(clients.id, Array.from(clientFkIds)))
: Promise.resolve([] as Array<{ id: string; name: string }>),
userFkIds.size > 0
? db
.select({
userId: userProfiles.userId,
displayName: userProfiles.displayName,
firstName: userProfiles.firstName,
lastName: userProfiles.lastName,
})
.from(userProfiles)
.where(inArray(userProfiles.userId, Array.from(userFkIds)))
: Promise.resolve(
[] as Array<{
userId: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
}>,
),
]);
const fkLabels: FkLabelMaps = {
berths: new Map(berthRows.map((b) => [b.id, `Berth ${b.mooring}`])),
yachts: new Map(yachtRows.map((y) => [y.id, y.name])),
clients: new Map(clientRows.map((c) => [c.id, c.name])),
users: new Map(
profileRows.map((p) => [
p.userId,
[p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName || 'User',
]),
),
};
// Union and sort
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
id: row.id,
@@ -117,6 +191,7 @@ export const GET = withAuth(
row.newValue as Record<string, unknown> | null,
(row.metadata as Record<string, unknown>) ?? {},
row.userId,
fkLabels,
),
userId: row.userId,
userName: resolveUserName(row.userId),
@@ -171,11 +246,19 @@ export const GET = withAuth(
}),
);
interface FkLabelMaps {
berths: Map<string, string>;
yachts: Map<string, string>;
clients: Map<string, string>;
users: Map<string, string>;
}
function buildAuditDescription(
action: string,
newValue: Record<string, unknown> | null,
metadata: Record<string, unknown>,
userId: string | null,
fkLabels: FkLabelMaps,
): string {
if (action === 'create') return 'Interest created';
if (action === 'archive') return 'Interest archived';
@@ -209,21 +292,63 @@ function buildAuditDescription(
return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
}
if (action === 'update') {
// Interest-berth link mutations get a sentence per flag transition
// ("Berth A1 added to EOI bundle") instead of a literal key/value
// dump. The audit row is logged against the parent interest, but
// `newValue` carries the interest_berths row's flags + the keying
// berthId - so the rep reads it as "what just happened on this
// berth link", not "field X changed to Y".
if (newValue && 'berthId' in newValue && typeof newValue.berthId === 'string') {
const berthLabel = fkLabels.berths.get(newValue.berthId) ?? '(removed berth)';
const phrases = describeInterestBerthFlags(newValue);
if (phrases.length > 0) {
return phrases.map((p) => p.replace('{berth}', berthLabel)).join(' · ');
}
}
// §1.1: surface which field(s) changed instead of a generic
// "Interest updated". We have the new-value bag in audit_logs;
// human-friendly labels for the most common fields.
return describeUpdateDiff(newValue);
return describeUpdateDiff(newValue, fkLabels);
}
return action;
}
/**
* Convert an `interest_berths` audit-log diff bag into one or more
* plain-English sentences. The audit row's `newValue` carries the
* post-state for one or more flag columns (`isPrimary`,
* `isInEoiBundle`, `isSpecificInterest`) plus the keying `berthId`.
* We narrate each flag transition individually so reps don't see a
* literal "X → on / Y → off" dump.
*
* `{berth}` is a placeholder the caller substitutes with the resolved
* mooring label (e.g. "Berth A1") - keeping the substitution out of
* here lets us return the same string shape for the "removed berth"
* fallback case.
*/
function describeInterestBerthFlags(newValue: Record<string, unknown>): string[] {
const phrases: string[] = [];
if (newValue.isPrimary === true) phrases.push('{berth} set as primary berth');
else if (newValue.isPrimary === false) phrases.push('{berth} no longer primary berth');
if (newValue.isInEoiBundle === true) phrases.push('{berth} added to EOI bundle');
else if (newValue.isInEoiBundle === false) phrases.push('{berth} removed from EOI bundle');
if (newValue.isSpecificInterest === true)
phrases.push('{berth} marked as specific interest (public Under Offer)');
else if (newValue.isSpecificInterest === false)
phrases.push('{berth} no longer marked as specific interest');
return phrases;
}
/**
* Render a "leadCategory: hot_lead, source: website" style description from
* an audit log's newValue payload. Filters out audit-internal fields,
* passes through human-friendly labels for known fields, falls back to
* the raw key name when the field isn't in the catalog.
*/
function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
function describeUpdateDiff(
newValue: Record<string, unknown> | null,
fkLabels: FkLabelMaps,
): string {
if (!newValue) return 'Interest updated';
// Audit-internal / housekeeping fields skipped from the timeline copy.
@@ -235,6 +360,7 @@ function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
assignedTo: 'owner',
yachtId: 'yacht',
berthId: 'primary berth',
clientId: 'client',
eoiDocStatus: 'EOI status',
reservationDocStatus: 'reservation status',
contractDocStatus: 'contract status',
@@ -255,14 +381,31 @@ function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
reminderDays: 'reminder cadence',
reminderNote: 'reminder note',
outcome: 'outcome',
isPrimary: 'primary-berth flag',
isInEoiBundle: 'in EOI bundle',
isSpecificInterest: 'specific-interest flag',
};
// FK-field → label-map lookup. When the audit row carries a UUID for
// one of these fields, we substitute the human label (mooring number,
// yacht/client name, user display name) instead of leaking the id.
const FK_FIELD_MAP: Record<string, keyof FkLabelMaps> = {
berthId: 'berths',
yachtId: 'yachts',
clientId: 'clients',
assignedTo: 'users',
ownerId: 'users',
createdBy: 'users',
reassignedTo: 'users',
};
const changed: string[] = [];
for (const [key, value] of Object.entries(newValue)) {
if (SKIP.has(key)) continue;
if (key === 'pipelineStage') continue; // handled by the earlier branch
const label = FIELD_LABELS[key] ?? key;
const formatted = formatDiffValue(value);
const label = FIELD_LABELS[key] ?? humanizeKey(key);
const fkMapKey = FK_FIELD_MAP[key];
const formatted = formatDiffValue(value, fkMapKey ? fkLabels[fkMapKey] : null);
changed.push(formatted ? `${label}${formatted}` : label);
}
@@ -272,11 +415,24 @@ function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
return `Updated ${changed.slice(0, 3).join(', ')} and ${changed.length - 3} more`;
}
function formatDiffValue(v: unknown): string {
function humanizeKey(key: string): string {
return key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase();
}
function formatDiffValue(v: unknown, fkLabelMap: Map<string, string> | null): string {
if (v === null || v === undefined) return 'cleared';
if (typeof v === 'boolean') return v ? 'on' : 'off';
if (typeof v === 'number') return String(v);
if (typeof v === 'string') {
// Resolve UUIDs through the supplied FK label map when present, so
// `berthId → a53e3b1d-...` renders as `primary berth → Berth A1`.
// Falls back to "(removed)" if the entity is gone, never the raw id.
if (fkLabelMap) {
return fkLabelMap.get(v) ?? '(removed)';
}
// Truncate verbose strings so the timeline line stays one row.
return v.length > 40 ? `${v.slice(0, 37)}` : v;
}

View File

@@ -11,7 +11,7 @@ import {
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
/**
* Phase 3 Custom document upload-to-Documenso endpoint.
* Phase 3 - Custom document upload-to-Documenso endpoint.
*
* POST `/api/v1/interests/[id]/upload-for-signing`
*
@@ -25,7 +25,7 @@ import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
* The Contract + Reservation tabs (Phase 4) post here from their
* drag-drop UI. Tests can invoke the service directly.
*
* Permission: documents.send_for_signing sending a document for
* Permission: documents.send_for_signing - sending a document for
* signing is destructive (queues an outbound email + an admin-visible
* Documenso doc). Plus interests.edit because the pipeline-stage
* auto-advance side-effect is interest-mutating (matches the
@@ -62,7 +62,7 @@ const fieldSchema = z.object({
fieldMeta: z.record(z.string(), z.unknown()).optional(),
});
const documentTypeSchema = z.enum(['contract', 'reservation_agreement']);
const documentTypeSchema = z.enum(['eoi', 'contract', 'reservation_agreement', 'generic']);
const MAX_PDF_BYTES = 50 * 1024 * 1024;
@@ -104,7 +104,7 @@ export const POST = withAuth(
throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
}
const buffer = Buffer.from(await file.arrayBuffer());
// Magic-byte check at the route boundary too service repeats it
// Magic-byte check at the route boundary too - service repeats it
// as defense in depth but a bad upload should error before we hit
// any side-effecting code.
if (!isPdfMagic(buffer)) {

View File

@@ -7,13 +7,13 @@ import { listInterestsForBoard } from '@/lib/services/interests.service';
import { boardFiltersSchema } from '@/lib/validators/interests';
/**
* Board (kanban) endpoint returns every active interest for the port
* Board (kanban) endpoint - returns every active interest for the port
* with a minimal projection (id, clientName, mooring, leadCategory,
* stage, updatedAt). No pagination: the kanban renders the whole
* pipeline at once. The service hard-caps at 5000 rows to keep payload
* size bounded; if `truncated: true` the UI surfaces a banner.
*
* Filter params are a strict subset of the list endpoint see
* Filter params are a strict subset of the list endpoint - see
* boardFiltersSchema. `pipelineStage` and `includeArchived` are
* intentionally rejected at validation time.
*/

View File

@@ -19,7 +19,7 @@ import { errorResponse } from '@/lib/errors';
* Synchronous bulk endpoint for the interests list.
*
* Per-row loop is fine for the page-size cap (100 rows max). Larger jobs
* (CSV imports, port-wide migrations) belong on the BullMQ `bulk` queue
* (CSV imports, port-wide migrations) belong on the BullMQ `bulk` queue -
* see src/lib/queue/workers/bulk.ts. The synchronous path gives the user
* instant feedback and a per-row failure list, which the queue can't.
*/

View File

@@ -7,7 +7,7 @@ import { env } from '@/lib/env';
* GET /api/v1/internal/dev-flags
*
* Read-only feed of dev-mode safety flags that the UI surfaces as
* always-visible badges. Authenticated (any signed-in user) these
* always-visible badges. Authenticated (any signed-in user) - these
* flags affect every outbound email so reps need to see them too,
* not just admins.
*

View File

@@ -7,7 +7,7 @@ import { logger } from '@/lib/logger';
* from `WebVitalsReporter` so it survives page unload. Body shape matches
* the `Metric` type from `web-vitals` v4.
*
* For now we log structured to pino once we have a perf-tracking table
* For now we log structured to pino - once we have a perf-tracking table
* (or external aggregator) wired, this can persist instead. The key value
* today is establishing the baseline before optimisation work.
*/

View File

@@ -17,7 +17,7 @@ const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
* table (so an S3↔filesystem swap carries it correctly), and writes
* the file id into `user_profiles.avatar_file_id`.
*
* Files are scoped to the user's CURRENT port the rep can't end up
* Files are scoped to the user's CURRENT port - the rep can't end up
* with an avatar that's only visible from one port. (Avatars render
* via the GET handler below, which presigns by id regardless of port.)
*/
@@ -98,7 +98,7 @@ export const POST = withAuth(async (req, ctx) => {
.where(eq(userProfiles.userId, ctx.userId));
if (priorAvatarId && priorAvatarId !== record.id) {
// Best-effort delete a stale-blob failure shouldn't fail the
// Best-effort delete - a stale-blob failure shouldn't fail the
// new-avatar response. deleteFile handles ref-check + blob
// delete + audit so a referenced file (somehow) is safe.
try {
@@ -111,7 +111,7 @@ export const POST = withAuth(async (req, ctx) => {
} catch (err) {
logger.warn(
{ err, priorAvatarId, userId: ctx.userId },
'avatar replace: failed to clean up prior avatar file orphan blob possible',
'avatar replace: failed to clean up prior avatar file - orphan blob possible',
);
}
}

View File

@@ -9,7 +9,7 @@ import { errorResponse, ValidationError } from '@/lib/errors';
import { env } from '@/lib/env';
/**
* Public confirmation endpoint clicked from the email sent to the
* Public confirmation endpoint - clicked from the email sent to the
* NEW address. Applies the email change atomically and redirects the
* user back to /settings with a success flag.
*

View File

@@ -45,7 +45,7 @@ export const PATCH = withAuth(async (req, ctx) => {
}
if (!REQUIRES_VERIFICATION) {
// Instant change dev only.
// Instant change - dev only.
const [updated] = await db
.update(user)
.set({ email, emailVerified: false, updatedAt: new Date() })
@@ -67,7 +67,7 @@ export const PATCH = withAuth(async (req, ctx) => {
return NextResponse.json({ data: { email: updated.email, instant: true } });
}
// Verification flow generate a single-use token, hash it, persist.
// Verification flow - generate a single-use token, hash it, persist.
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_MINUTES * 60 * 1000);
@@ -111,7 +111,7 @@ export const PATCH = withAuth(async (req, ctx) => {
const confirmBody = `
<p style="margin-bottom:16px;">Hi,</p>
<p style="margin-bottom:16px;">You (or someone using your account) requested to change the sign-in email on your ${appName} account from <strong>${safeOldEmail}</strong> to <strong>${safeNewEmail}</strong>.</p>
<p style="margin-bottom:16px;"><a href="${safeUrl(confirmUrl)}" style="color:#2563eb;font-weight:600;">Click here to confirm this change</a> the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
<p style="margin-bottom:16px;"><a href="${safeUrl(confirmUrl)}" style="color:#2563eb;font-weight:600;">Click here to confirm this change</a> - the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
<p style="color:#64748b;">If you didn't request this, ignore this email.</p>
`;
const cancelBody = `

View File

@@ -10,7 +10,7 @@ import { errorResponse } from '@/lib/errors';
* one-time reset token and dispatches the email via the
* `sendResetPassword` callback configured in src/lib/auth/index.ts.
*
* The email always goes to the user's CURRENT account email no way
* The email always goes to the user's CURRENT account email - no way
* to redirect to a different inbox here, so the endpoint is safe even
* if a session is hijacked (the attacker can't move the reset email
* to themselves).

View File

@@ -13,14 +13,14 @@ import { errorResponse } from '@/lib/errors';
*
* M-NEW-1: this endpoint INTENTIONALLY skips `withAuth`'s port-context
* requirement. Callers hit /me/ports specifically to LEARN which ports
* they have access to they can't have selected one yet, so the
* they have access to - they can't have selected one yet, so the
* X-Port-Id header is by definition absent on the first call. Pre-fix
* this meant non-super-admins got a 400 "Port context required" and
* the client had to special-case the response shape.
*
* Auth is still enforced (session check); permissions logic skipped
* because the endpoint exposes only IDs+slugs+names of ports the user
* is already a member of same surface area as a `me` profile read.
* is already a member of - same surface area as a `me` profile read.
*/
export async function GET() {
try {

View File

@@ -18,12 +18,12 @@ const updateProfileSchema = z.object({
* Optional sign-in alias. `null` clears the existing value; a string
* must match the 230 lowercase shape pinned by USERNAME_REGEX (also
* enforced by `chk_user_profiles_username_shape` in migration 0054).
* Uniqueness is checked below before the UPDATE collisions surface
* Uniqueness is checked below before the UPDATE - collisions surface
* as a 409 with a friendly message.
*/
username: z.union([z.string().transform((s) => s.trim().toLowerCase()), z.null()]).optional(),
phone: z.string().nullable().optional(),
// Refuse `javascript:` / `data:` schemes z.string().url() lets them
// Refuse `javascript:` / `data:` schemes - z.string().url() lets them
// through and `<a href={avatarUrl}>` would otherwise be a stored-XSS
// vector if any future renderer treated the value as a link.
avatarUrl: z
@@ -32,7 +32,7 @@ const updateProfileSchema = z.object({
.refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL')
.nullable()
.optional(),
// Strict allow-list no `.passthrough()` here. The previous schema let
// Strict allow-list - no `.passthrough()` here. The previous schema let
// arbitrary client-supplied keys survive validation and persist into
// `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this.
// Add new keys here as the UI surfaces them rather than letting the
@@ -51,7 +51,7 @@ const updateProfileSchema = z.object({
// be set via hand-rolled SQL because the allow-list at line
// 154 silently stripped unknown keys.
defaultPortId: z.string().uuid().optional(),
// Per-table column visibility. Keyed by entity type entries
// Per-table column visibility. Keyed by entity type - entries
// with an empty `hiddenColumns` mean "all visible". The validator
// caps total entries / IDs so a malicious client can't bloat the
// 8 KB preferences blob; see merge step below for the byte cap.
@@ -65,7 +65,7 @@ const updateProfileSchema = z.object({
.strict(),
)
.optional(),
// Phase 4 per-user default reminder firing time-of-day. HH:MM in
// Phase 4 - per-user default reminder firing time-of-day. HH:MM in
// 24h local clock (validated below). Per-reminder dueAt overrides
// this; this is only the dialog's default when the rep doesn't
// pick an explicit time. Server clamps to '00:00''23:59'.
@@ -158,7 +158,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
}
}
if (body.preferences !== undefined) {
// Allow-list only retain keys defined in the strict schema. Pre-
// Allow-list - only retain keys defined in the strict schema. Pre-
// strict rows may carry extra keys from when the schema was
// .passthrough(); the merge prunes them so legacy bloat doesn't
// accumulate forever, and a future schema regression that tries
@@ -169,7 +169,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
'timezone',
'tablePreferences',
'defaultPortId',
// Phase 4 reminder default firing time.
// Phase 4 - reminder default firing time.
'digestTimeOfDay',
]);
const existing = (profile.preferences as Record<string, unknown>) ?? {};
@@ -178,7 +178,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
ALLOWED_PREF_KEYS.has(k),
),
);
// Hard cap on the merged JSONB defense in depth against any
// Hard cap on the merged JSONB - defense in depth against any
// future schema growth that might re-introduce free-form keys.
const serialized = JSON.stringify(merged);
if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
@@ -190,7 +190,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
// concurrency-auditor M-2: pre-check at line 132-139 is TOCTOU
// against `idx_user_profiles_username_unique`. Two concurrent claims
// on the same username will see "available" in their own pre-check
// and the loser's UPDATE fails with 23505 surface that as
// and the loser's UPDATE fails with 23505 - surface that as
// ConflictError rather than letting it bubble as a generic 500.
let updated;
try {

View File

@@ -66,7 +66,7 @@ const requestSchema = z.object({
* new permission added in seed-data; defaults to true for super-
* admins and sales-managers, false for sales-agents). The route also
* defends in depth via per-section service calls, each of which
* reapplies the active port filter a rep with reports.export but
* reapplies the active port filter - a rep with reports.export but
* no clients.view still gets a clients table in the PDF, since the
* export is a snapshot of state the rep already has dashboard access
* to. Defer narrower per-section gating to a follow-up once the
@@ -84,7 +84,10 @@ export const POST = withAuth(
const data: ReportData = {};
switch (body.config.kind) {
case 'dashboard':
data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds);
data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds, {
dateFrom: body.config.dateFrom,
dateTo: body.config.dateTo,
});
break;
case 'clients':
data.clients = await resolveClientReportData(ctx.portId, {
@@ -122,7 +125,7 @@ export const POST = withAuth(
});
// Audit BEFORE returning so a failed write doesn't silently
// leak an export. The `void` is intentional audit is best-
// leak an export. The `void` is intentional - audit is best-
// effort relative to the export; the PDF download succeeding
// is the contract.
void createAuditLog({
@@ -149,7 +152,7 @@ export const POST = withAuth(
const filename = sanitizeFilename(`${body.title}.pdf`);
// Stream the buffer back inline. The Content-Disposition uses
// `attachment; filename*` (RFC 5987) for unicode titles
// `attachment; filename*` (RFC 5987) for unicode titles -
// Port names with diacritics need this or browsers fall back
// to a mojibake'd ASCII filename. `filename=` carries the
// ASCII fallback for older HTTP stacks.

View File

@@ -21,10 +21,10 @@ const patchBodySchema = z
});
/**
* GET single template (used by the dialog to hydrate a saved
* GET - single template (used by the dialog to hydrate a saved
* template into the form when the rep picks it).
* PATCH rename, retitle, or rewrite the config.
* DELETE remove. Cascade-safe: no FKs reference a saved template.
* PATCH - rename, retitle, or rewrite the config.
* DELETE - remove. Cascade-safe: no FKs reference a saved template.
*/
export const GET = withAuth(
withPermission('reports', 'export', async (_req, ctx, params) => {

View File

@@ -25,7 +25,7 @@ const createBodySchema = z.object({
* Persist a template. The dialog calls this when the rep ticks
* "Save as template" while configuring an export.
*
* Both gated on `reports.export` the same permission that lets
* Both gated on `reports.export` - the same permission that lets
* the rep generate reports also lets them save templates.
*/
export const GET = withAuth(

View File

@@ -11,7 +11,7 @@ import {
import { PIPELINE_STAGES } from '@/lib/validators/residential';
/**
* Synchronous bulk endpoint for the residential interests list mirrors
* Synchronous bulk endpoint for the residential interests list - mirrors
* the `/api/v1/interests/bulk` shape (and the new `/api/v1/berths/bulk`)
* so the rep-facing UX is consistent. Per-row loop with a 100-id cap.
*

View File

@@ -13,7 +13,7 @@ const listQuerySchema = z.object({
// Saved views are owner-only by design: every service call filters by
// (portId, userId), so any authenticated user can manage exactly their
// own views. We deliberately skip `withPermission(...)` here there is
// own views. We deliberately skip `withPermission(...)` here - there is
// no resource-level permission to add. See `savedViewsService` for the
// ownership filter that backs this route.
export const GET = withAuth(async (req, ctx) => {

View File

@@ -9,7 +9,7 @@ import { getRecentlyViewed, trackView } from '@/lib/services/recently-viewed.ser
import { trackViewSchema } from '@/lib/validators/search';
/**
* Hydrated row returned by GET contains the same `type` + `id` from
* Hydrated row returned by GET - contains the same `type` + `id` from
* the Redis sorted set, plus the labels needed to render the row in the
* dropdown ("Client · Jane Smith") without an extra round-trip.
*

View File

@@ -34,7 +34,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
]);
// Resolve `:portSlug` placeholders in the nav-catalog hrefs. Done
// here (not in the service) so the service stays portSlug-free
// here (not in the service) so the service stays portSlug-free -
// it only knows portId, which is the right tenant boundary anyway.
if (results.navigation.length > 0) {
results.navigation = results.navigation.map((n) => ({
@@ -46,7 +46,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
// Re-run affinity sort with the now-resolved set. The service can
// also accept this up front, but we kick off both queries in
// parallel for latency, then apply on results affinity is just a
// parallel for latency, then apply on results - affinity is just a
// post-sort, so order of operations doesn't change correctness.
if (touchedIds.size > 0) {
const reorder = <T extends { id: string }>(rows: T[]) => {
@@ -68,7 +68,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
results.reminders = reorder(results.reminders);
}
// Fire-and-forget recent search history is non-critical.
// Fire-and-forget - recent search history is non-critical.
if (parsed.q.length >= 2) {
saveRecentSearch(ctx.userId, ctx.portId, parsed.q);
}

View File

@@ -11,7 +11,7 @@ import { errorResponse } from '@/lib/errors';
*
* Mints a new tracked redirect-link the rep can drop into an outgoing
* email or chat. Body: { targetUrl, sendId? }. Returns the slug + the
* full public URL (`<APP_URL>/q/<slug>`) caller pastes the URL into
* full public URL (`<APP_URL>/q/<slug>`) - caller pastes the URL into
* the message draft.
*
* Gated on `email.send` since this surface is consumed from compose UIs.

Some files were not shown because too many files have changed in this diff Show More