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

@@ -94,7 +94,7 @@ export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
// ─── Archive Berth ────────────────────────────────────────────────────────────
// Post-audit F5: archive replaces hard-delete. A `reason` is required so
// the audit trail captures intent "decommissioned 2026", "duplicate of
// the audit trail captures intent - "decommissioned 2026", "duplicate of
// A3", etc. min(5) blocks one-letter throwaways.
export const archiveBerthSchema = z.object({
reason: z.string().trim().min(5, 'Reason must be at least 5 characters'),

View File

@@ -3,7 +3,7 @@ import { createInsertSchema, createUpdateSchema } from 'drizzle-zod';
import { brochures } from '@/lib/db/schema/brochures';
// Derived from the Drizzle table adding a column to `brochures`
// Derived from the Drizzle table - adding a column to `brochures`
// auto-includes it here. Refinements override per-field.
export const createBrochureSchema = createInsertSchema(brochures, {
label: (s) => s.trim().min(1).max(120),
@@ -21,7 +21,7 @@ export const registerBrochureVersionSchema = z.object({
.min(1)
.max(500)
// Mirrors the `validateStorageKey` regex in `src/lib/storage/filesystem.ts`
// defense-in-depth against path-traversal payloads from the client.
// - defense-in-depth against path-traversal payloads from the client.
.regex(/^[a-zA-Z0-9/_.-]+$/, 'Invalid storage key format')
.refine((s) => !s.includes('..'), 'Storage key may not contain ".."')
.refine((s) => !s.startsWith('/'), 'Storage key may not be absolute'),

View File

@@ -41,7 +41,7 @@ const createTemplateBaseSchema = z.object({
bodyHtml: z.string().min(1).optional(),
mergeFields: mergeFieldsSchema,
isActive: z.boolean().default(true),
// Phase 7.1 PDF overlay markers (percent-coord) saved by the
// Phase 7.1 - PDF overlay markers (percent-coord) saved by the
// in-app editor. Reused by templateFormat='pdf_overlay' at fill time.
// Stays optional so legacy html/pdf_form templates can be PATCHed
// without an empty array round-trip.
@@ -70,7 +70,7 @@ export const generateSchema = z.object({
});
/**
* Phase 3b per-field override descriptor used by the EOI dialog.
* Phase 3b - per-field override descriptor used by the EOI dialog.
*
* Three modes:
* - `useOnlyForThisEoi=true` → write the value to documents.override_*
@@ -87,7 +87,7 @@ const fieldOverrideSchema = z.object({
useOnlyForThisEoi: z.boolean().default(false),
setAsDefault: z.boolean().default(false),
/** When the value comes from an existing client_contacts row, the rep
* picked it from the combobox pass the id so the service can skip
* picked it from the combobox - pass the id so the service can skip
* re-inserting and just promote it (when setAsDefault is set). */
contactId: z.string().uuid().optional(),
});
@@ -109,13 +109,13 @@ export const generateAndSignSchema = generateSchema.extend({
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
* server defaults to the yacht's `lengthUnit` column when omitted. */
dimensionUnit: z.enum(['ft', 'm']).optional(),
/** Phase 3b/3-follow-up optional per-field overrides applied at generation. */
/** Phase 3b/3-follow-up - optional per-field overrides applied at generation. */
overrides: z
.object({
clientEmail: fieldOverrideSchema.optional(),
clientPhone: fieldOverrideSchema.optional(),
yachtName: fieldOverrideSchema.optional(),
// Phase 3 follow-up multi-component address override. Treated as
// Phase 3 follow-up - multi-component address override. Treated as
// one logical "field" with one pair of checkboxes (the dialog
// surfaces it that way, and the side-effects helper applies the
// intent to the whole address rather than per-component).

View File

@@ -104,7 +104,7 @@ export const listDocumentsSchema = baseListQuerySchema
.optional(),
sentSince: z.string().datetime().optional(),
sentUntil: z.string().datetime().optional(),
/** Entity-aggregated projection params mutually exclusive with folderId. */
/** Entity-aggregated projection params - mutually exclusive with folderId. */
entityType: z.enum(['client', 'company', 'yacht']).optional(),
entityId: z.string().uuid().optional(),
})

View File

@@ -3,7 +3,7 @@ import { baseListQuerySchema } from '@/lib/api/list-query';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
/**
* Inner-shape ZodObject kept exported (without .refine wrapping) so
* Inner-shape ZodObject - kept exported (without .refine wrapping) so
* `updateExpenseSchema` can still call `.partial()`. The `.refine()` rule
* for "receipt or acknowledgement" is applied via `createExpenseSchema`.
*/

View File

@@ -29,7 +29,7 @@ export const listFilesSchema = baseListQuerySchema
.uuid()
.optional()
.transform((v) => (v === '' ? null : v)),
/** Entity-aggregated projection params mutually exclusive with folderId. */
/** Entity-aggregated projection params - mutually exclusive with folderId. */
entityType: z.enum(['client', 'company', 'yacht']).optional(),
entityId: z.string().uuid().optional(),
})

View File

@@ -163,7 +163,7 @@ export const listInterestsSchema = baseListQuerySchema.extend({
/**
* Filters accepted by GET /api/v1/interests/board. Strict subset of
* listInterestsSchema `pipelineStage` and `includeArchived` are
* listInterestsSchema - `pipelineStage` and `includeArchived` are
* intentionally omitted (the columns ARE the stages, archived deals
* never belong on the board). No pagination params either.
*/

View File

@@ -83,7 +83,7 @@ export const listInvoicesSchema = baseListQuerySchema.extend({
// `z.input` keeps fields with `.default()` (paymentTerms, currency, kind)
// optional from the caller's perspective. The route layer runs the
// schema through `parseBody`, so the service body can rely on those
// defaults being present at runtime narrow with a local cast where
// defaults being present at runtime - narrow with a local cast where
// the post-parse shape matters (e.g. coerced `unitPrice` is `number`).
export type CreateInvoiceInput = z.input<typeof createInvoiceSchema>;
export type UpdateInvoiceInput = z.input<typeof updateInvoiceSchema>;

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
/**
* Per-port qualification criterion. Admin-configurable: label / description /
* enabled state / display order. The `key` is the stable identifier code
* references (templates, derivations) it can't be changed after creation
* references (templates, derivations) - it can't be changed after creation
* because per-interest state rows reference it via composite PK.
*/
export const createQualificationCriterionSchema = z.object({
@@ -24,7 +24,7 @@ export const updateQualificationCriterionSchema = createQualificationCriterionSc
/**
* Whole-list reorder. The IDs array must cover exactly the port's current
* criteria the service rejects partial / extraneous IDs to keep the
* criteria - the service rejects partial / extraneous IDs to keep the
* resulting display_order contiguous.
*/
export const reorderQualificationCriteriaSchema = z.object({
@@ -33,7 +33,7 @@ export const reorderQualificationCriteriaSchema = z.object({
/**
* Per-interest qualification state. Only `confirmed` + optional `notes` are
* writable `confirmedAt` / `confirmedBy` are stamped server-side from
* writable - `confirmedAt` / `confirmedBy` are stamped server-side from
* the auth context.
*/
export const setInterestQualificationSchema = z.object({

View File

@@ -47,7 +47,7 @@ export const listResidentialClientsSchema = baseListQuerySchema.extend({
// ─── Residential interest ────────────────────────────────────────────────────
/**
* Default pipeline stages used as the fallback when a port hasn't
* Default pipeline stages - used as the fallback when a port hasn't
* configured its own list via the residential admin page. Mirror the
* legacy hard-coded set so existing data continues to validate.
*
@@ -87,7 +87,7 @@ export const listResidentialInterestsSchema = baseListQuerySchema.extend({
// multi-select. The legacy single-string form stays accepted so existing
// callers (e.g. residential-client-tabs) don't need to migrate.
pipelineStage: z.union([z.string(), z.array(z.string())]).optional(),
// Source filter mirrors the main interest list. Comma-separated when
// Source filter - mirrors the main interest list. Comma-separated when
// submitted as a query string ("website,referral").
source: z.union([z.string(), z.array(z.string())]).optional(),
assignedTo: z.string().optional(),

View File

@@ -20,13 +20,13 @@ const BUCKET_TYPES = [
export const searchQuerySchema = z.object({
// 2-char minimum keeps `to_tsquery('a:*')` from returning every word
// starting with "a" short queries return overwhelming match sets.
// starting with "a" - short queries return overwhelming match sets.
q: z.string().min(2).max(200),
/** Restrict the result set to a single bucket. */
type: z.enum(BUCKET_TYPES).optional(),
/** Per-bucket cap. Defaults to 5 (dropdown). 25 is the typical /search-page value. */
limit: z.coerce.number().int().min(1).max(50).optional(),
/** Super-admin only search ports beyond the current one. */
/** Super-admin only - search ports beyond the current one. */
includeOtherPorts: z
.union([z.literal('true'), z.literal('1'), z.literal('false'), z.literal('0')])
.transform((v) => v === 'true' || v === '1')

View File

@@ -3,7 +3,7 @@ import { createInsertSchema } from 'drizzle-zod';
import { tags } from '@/lib/db/schema/system';
// Derive the schema from the Drizzle table adding a column to `tags`
// Derive the schema from the Drizzle table - adding a column to `tags`
// auto-includes it here; omitting / refining is per-field. Eliminates
// the validator-drift class of bugs the audit flagged.
export const createTagSchema = createInsertSchema(tags, {

View File

@@ -22,11 +22,18 @@ export const updateUserPreferencesSchema = z.object({
*/
dashboardWidgets: z.record(z.string(), z.boolean()).optional(),
/**
* Rep-chosen dashboard widget order (drag-drop). Missing ids fall
* through to registry order so newly-added widgets always surface.
* Kept loose (array-of-string) for the same reason as above.
* Rep-chosen dashboard widget order for the **desktop / xl layout**
* (>= 1280px). Missing ids fall through to registry order so newly-
* added widgets always surface.
*/
dashboardWidgetOrder: z.array(z.string()).optional(),
/**
* Rep-chosen dashboard widget order for the **stacked layout**
* (< 1280px - single column on tablet/mobile). When unset, the
* dashboard falls back to `dashboardWidgetOrder` (then registry
* order).
*/
dashboardWidgetOrderMobile: z.array(z.string()).optional(),
});
export type UpdateUserPreferencesInput = z.infer<typeof updateUserPreferencesSchema>;

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
/**
* Canonical username shape.
*
* - 2..30 characters (yes, 2 initials like "dm" are real and the
* - 2..30 characters (yes, 2 - initials like "dm" are real and the
* director uses them)
* - lowercase letters, digits, `.`, `_`, `-`
* - case-insensitive uniqueness is enforced by a partial unique index on

View File

@@ -40,7 +40,7 @@ function isBlockedIpv4(host: string): boolean {
if (a === 0) return true; // 0/8 zero
if (a >= 224) return true; // multicast / reserved
// outbound-webhook-auditor M1: Oracle Cloud metadata endpoint
// (192.0.0.192) was missing from the original denylist.
// (192.0.0.192) - was missing from the original denylist.
if (a === 192 && b === 0 && c === 0 && d === 192) return true;
return false;
}

View File

@@ -34,7 +34,7 @@ export const createYachtSchema = z.object({
status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'),
notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
// Phase 3c origin tracking. Defaults to 'manual'; the EOI spawn flow
// Phase 3c - origin tracking. Defaults to 'manual'; the EOI spawn flow
// sends 'eoi-generated' and the migration-0073 CHECK enforces the
// values at the DB level.
source: z.enum(['manual', 'imported', 'eoi-generated']).optional(),