Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { z } from 'zod';
|
|
|
|
|
|
2026-05-06 14:56:59 +02:00
|
|
|
import { baseListQuerySchema } from '@/lib/api/list-query';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
|
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
|
|
|
import {
|
|
|
|
|
optionalCountryIsoSchema,
|
|
|
|
|
optionalPhoneE164Schema,
|
|
|
|
|
optionalSubdivisionIsoSchema,
|
|
|
|
|
} from '@/lib/validators/i18n';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// ─── Create ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-05 02:49:01 +02:00
|
|
|
/**
|
|
|
|
|
* Desired-dimension input. Strings/numbers are coerced to a positive
|
|
|
|
|
* decimal (string-typed for postgres `numeric` column compatibility);
|
|
|
|
|
* empty strings collapse to `undefined` so a blank form field doesn't
|
|
|
|
|
* round-trip "" → numeric error on the API.
|
|
|
|
|
*/
|
|
|
|
|
const optionalDesiredDimSchema = z
|
|
|
|
|
.union([z.string(), z.number()])
|
|
|
|
|
.optional()
|
|
|
|
|
.transform((v) => {
|
|
|
|
|
if (v === undefined || v === null || v === '') return undefined;
|
|
|
|
|
const n = typeof v === 'number' ? v : parseFloat(v);
|
|
|
|
|
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
|
|
|
return String(Math.round(n * 100) / 100);
|
|
|
|
|
});
|
|
|
|
|
|
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
pipeline stage of any active linked interest (server-aggregated, ranks by
PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
combobox: search, recent-first sort, stage-coloured pills
Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
"10% Deposit → Contract Sent"
EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
framed by short copy explaining what's inline vs what needs the canonical
page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
PATCH without an extra round-trip
Company form
- New "Connections" section lets the rep attach members (clients) and yachts
during create. Yacht attach uses the existing transfer endpoint so audit
log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
client owns yachts not yet linked) and an optional "Create interest" step
pre-filled with the first attached client
Admin
- /admin landing gains a searchable index — typed query flattens groups into
a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
with the user-facing language rename from round 1)
Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
the rep's literal entry (ft OR m) is preserved verbatim instead of being
reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
derived from the ft canonical to keep the recommender SQL unchanged
Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
to include the new id + unit fields on the EoiContext / Berth shapes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
|
|
|
const desiredUnitSchema = z.enum(['ft', 'm']).optional();
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
export const createInterestSchema = z.object({
|
|
|
|
|
clientId: z.string().min(1),
|
2026-04-24 15:34:44 +02:00
|
|
|
yachtId: z.string().optional(),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berthId: z.string().optional(),
|
|
|
|
|
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
|
|
|
|
|
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
|
|
|
|
source: z.string().optional(),
|
|
|
|
|
tagIds: z.array(z.string()).optional().default([]),
|
2026-05-06 22:28:41 +02:00
|
|
|
// Omitting reminderEnabled / reminderDays falls back to the per-port
|
|
|
|
|
// defaults configured at /admin/reminders (resolved in
|
|
|
|
|
// createInterest). To opt out explicitly pass false / null.
|
|
|
|
|
reminderEnabled: z.boolean().optional(),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
reminderDays: z.number().int().min(1).optional(),
|
2026-05-05 02:49:01 +02:00
|
|
|
desiredLengthFt: optionalDesiredDimSchema,
|
|
|
|
|
desiredWidthFt: optionalDesiredDimSchema,
|
|
|
|
|
desiredDraftFt: optionalDesiredDimSchema,
|
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
pipeline stage of any active linked interest (server-aggregated, ranks by
PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
combobox: search, recent-first sort, stage-coloured pills
Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
"10% Deposit → Contract Sent"
EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
framed by short copy explaining what's inline vs what needs the canonical
page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
PATCH without an extra round-trip
Company form
- New "Connections" section lets the rep attach members (clients) and yachts
during create. Yacht attach uses the existing transfer endpoint so audit
log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
client owns yachts not yet linked) and an optional "Create interest" step
pre-filled with the first attached client
Admin
- /admin landing gains a searchable index — typed query flattens groups into
a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
with the user-facing language rename from round 1)
Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
the rep's literal entry (ft OR m) is preserved verbatim instead of being
reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
derived from the ft canonical to keep the recommender SQL unchanged
Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
to include the new id + unit fields on the EoiContext / Berth shapes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
|
|
|
desiredLengthM: optionalDesiredDimSchema,
|
|
|
|
|
desiredWidthM: optionalDesiredDimSchema,
|
|
|
|
|
desiredDraftM: optionalDesiredDimSchema,
|
|
|
|
|
desiredLengthUnit: desiredUnitSchema,
|
|
|
|
|
desiredWidthUnit: desiredUnitSchema,
|
|
|
|
|
desiredDraftUnit: desiredUnitSchema,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const updateInterestSchema = createInterestSchema
|
|
|
|
|
.omit({ clientId: true, tagIds: true })
|
|
|
|
|
.partial();
|
|
|
|
|
|
|
|
|
|
// ─── Change Stage ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const changeStageSchema = z.object({
|
|
|
|
|
pipelineStage: z.enum(PIPELINE_STAGES),
|
|
|
|
|
reason: z.string().optional(),
|
feat(interests): manual stage override + Residential Partner system role
Manual stage override
Sales reps need to skip canTransitionStage rules when the data was
entered out of order — e.g. recording a contract_signed deal whose
earlier stages were never tracked in the system.
- New permission flag interests.override_stage in RolePermissions.
Plumbed through the schema TS type, the role-editor UI, the seed
file's pre-built roles (super_admin/director/sales_manager get it,
sales_agent + viewer don't), and the test factories.
- changeStageSchema gains an optional `override` boolean and the
service checks it before evaluating canTransitionStage. When
override=true the reason field becomes required (min 5 chars) and
is recorded in the audit log.
- The route handler gates `override` on the new permission so a
sales_agent without it can't pass override=true and bypass.
- InterestStagePicker auto-detects when the requested transition is
blocked by the table and switches into "override mode" — shows an
amber warning, requires the reason, button label flips to
"Override stage". When the operator lacks the permission, the
warning is red and the button is disabled.
Residential Partner role
Per the smart-archive scoping conversation: external partners who
handle residential inquiries shouldn't see marina clients, yachts,
berths, or financials. The two residential_* permission groups
already exist; this commit just seeds a pre-built system role
("residential_partner") with those flags + minimal own-reminders, so
admins can invite a partner today via /admin/users without manually
building the permission set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:32:57 +02:00
|
|
|
/** Bypass the canTransitionStage transition table. Requires the caller
|
|
|
|
|
* to hold the `interests.override_stage` permission. Reason becomes
|
|
|
|
|
* required when override=true (recorded in the audit log). */
|
|
|
|
|
override: z.boolean().optional(),
|
2026-05-12 14:50:58 +02:00
|
|
|
/** Optional ISO date (YYYY-MM-DD or full ISO timestamp) to stamp on the
|
|
|
|
|
* matching milestone column instead of "now". Used when a rep marks a
|
|
|
|
|
* milestone manually (e.g. deposit received yesterday) so the recorded
|
|
|
|
|
* date reflects the real event instead of the click time. */
|
|
|
|
|
milestoneDate: z
|
|
|
|
|
.string()
|
|
|
|
|
.regex(/^\d{4}-\d{2}-\d{2}(T.*)?$/)
|
|
|
|
|
.optional(),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
|
2026-05-02 00:01:33 +02:00
|
|
|
// ─── Outcome (Won / Lost) ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const INTEREST_OUTCOMES = [
|
|
|
|
|
'won',
|
|
|
|
|
'lost_other_marina',
|
|
|
|
|
'lost_unqualified',
|
|
|
|
|
'lost_no_response',
|
2026-05-12 14:50:58 +02:00
|
|
|
'lost_other',
|
2026-05-02 00:01:33 +02:00
|
|
|
'cancelled',
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
export type InterestOutcome = (typeof INTEREST_OUTCOMES)[number];
|
|
|
|
|
|
|
|
|
|
export const setOutcomeSchema = z.object({
|
|
|
|
|
outcome: z.enum(INTEREST_OUTCOMES),
|
|
|
|
|
reason: z.string().max(2000).optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const clearOutcomeSchema = z.object({
|
|
|
|
|
// Stage to revert to when reopening (defaults to in_communication).
|
|
|
|
|
reopenStage: z.enum(PIPELINE_STAGES).optional(),
|
|
|
|
|
});
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const listInterestsSchema = baseListQuerySchema.extend({
|
|
|
|
|
clientId: z.string().optional(),
|
2026-04-24 15:34:44 +02:00
|
|
|
yachtId: z.string().optional(),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berthId: z.string().optional(),
|
|
|
|
|
pipelineStage: z
|
|
|
|
|
.string()
|
|
|
|
|
.transform((v) => v.split(',').filter(Boolean))
|
|
|
|
|
.optional(),
|
|
|
|
|
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
|
|
|
|
eoiStatus: z.string().optional(),
|
|
|
|
|
tagIds: z
|
|
|
|
|
.string()
|
|
|
|
|
.transform((v) => v.split(',').filter(Boolean))
|
|
|
|
|
.optional(),
|
|
|
|
|
});
|
|
|
|
|
|
feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.
EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.
Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.
Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.
Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).
Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).
Berth interest list overhaul:
- Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
- Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
- Per-letter row tinting via colored left-border accent + dot in cell
- Documents tab merged Files (single attachments section)
Topbar improvements:
- Always-visible back arrow on detail pages (path depth > 2)
- Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
push their entity hierarchy (Clients › Mary Smith › Interest › B17)
- Tighter spacing, softer separators, 160px crumb truncation
DataTable upgrades:
- Page-size selector with All option (validator cap raised to 1000)
- getRowClassName slot for per-row styling (used by berth tinting)
- Fixed Radix SelectItem crash on empty-string values via __any__
sentinel (was crashing every list page that opened a select filter)
Interest list:
- Configurable columns picker
- Stage cell clickable into detail
- TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
- Save view moved into ColumnPicker menu; Views button hidden when
no views are saved
- Pipeline kanban board endpoint at /api/v1/interests/board with
minimal projection, 5000-row cap + truncated banner, filter
pass-through
Mobile chrome + sidebar collapse removed (always-expanded design choice).
User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
|
|
|
// ─── Board (kanban) ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Filters accepted by GET /api/v1/interests/board. Strict subset of
|
|
|
|
|
* listInterestsSchema — `pipelineStage` and `includeArchived` are
|
|
|
|
|
* intentionally omitted (the columns ARE the stages, archived deals
|
|
|
|
|
* never belong on the board). No pagination params either.
|
|
|
|
|
*/
|
|
|
|
|
export const boardFiltersSchema = z.object({
|
|
|
|
|
search: z.string().optional(),
|
|
|
|
|
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
|
|
|
|
source: z.string().optional(),
|
|
|
|
|
eoiStatus: z.string().optional(),
|
|
|
|
|
tagIds: z
|
|
|
|
|
.string()
|
|
|
|
|
.transform((v) => v.split(',').filter(Boolean))
|
|
|
|
|
.optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export type BoardFiltersInput = z.infer<typeof boardFiltersSchema>;
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Waiting List ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const waitingListAddSchema = z.object({
|
|
|
|
|
clientId: z.string().min(1),
|
|
|
|
|
priority: z.enum(['normal', 'high']).default('normal'),
|
|
|
|
|
notifyPref: z.enum(['email', 'in_app', 'both']).default('email'),
|
|
|
|
|
notes: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Generate Recommendations ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const generateRecommendationsSchema = z.object({
|
|
|
|
|
interestId: z.string().min(1),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Public Interest ──────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-14 12:53:13 -04:00
|
|
|
const addressSchema = z.object({
|
|
|
|
|
street: z.string().max(500).optional(),
|
|
|
|
|
city: z.string().max(200).optional(),
|
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
|
|
|
/** ISO 3166-2 subdivision code (e.g. 'PL-MZ'). */
|
|
|
|
|
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
2026-04-14 12:53:13 -04:00
|
|
|
postalCode: z.string().max(50).optional(),
|
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
|
|
|
/** ISO-3166-1 alpha-2 country code. */
|
|
|
|
|
countryIso: optionalCountryIsoSchema.optional(),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
|
2026-04-24 15:42:45 +02:00
|
|
|
// Nested yacht block. Public submissions must now include yacht data because the
|
|
|
|
|
// route inserts a yacht row as part of the trio (client + yacht + interest).
|
|
|
|
|
const publicYachtSchema = z.object({
|
|
|
|
|
name: z.string().min(1).max(200),
|
|
|
|
|
hullNumber: z.string().max(100).optional(),
|
|
|
|
|
registration: z.string().max(100).optional(),
|
|
|
|
|
flag: z.string().max(100).optional(),
|
|
|
|
|
yearBuilt: z.coerce.number().int().min(1800).max(2100).optional(),
|
|
|
|
|
lengthFt: z.coerce.number().positive().optional(),
|
|
|
|
|
widthFt: z.coerce.number().positive().optional(),
|
|
|
|
|
draftFt: z.coerce.number().positive().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Optional company block. If provided, the route upserts a company row (match
|
|
|
|
|
// case-insensitively by (portId, name)) and adds an active membership linking
|
|
|
|
|
// the submitting client to the company with the chosen role.
|
|
|
|
|
const publicCompanySchema = z.object({
|
|
|
|
|
name: z.string().min(1).max(200),
|
|
|
|
|
legalName: z.string().max(200).optional(),
|
|
|
|
|
taxId: z.string().max(100).optional(),
|
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
|
|
|
/** ISO-3166-1 alpha-2 country of incorporation. */
|
|
|
|
|
incorporationCountryIso: optionalCountryIsoSchema.optional(),
|
|
|
|
|
/** ISO 3166-2 state/province of incorporation. */
|
|
|
|
|
incorporationSubdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
2026-04-24 15:42:45 +02:00
|
|
|
role: z
|
|
|
|
|
.enum([
|
|
|
|
|
'director',
|
|
|
|
|
'officer',
|
|
|
|
|
'broker',
|
|
|
|
|
'representative',
|
|
|
|
|
'legal_counsel',
|
|
|
|
|
'employee',
|
|
|
|
|
'shareholder',
|
|
|
|
|
'other',
|
|
|
|
|
])
|
|
|
|
|
.optional()
|
|
|
|
|
.default('representative'),
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 12:53:13 -04:00
|
|
|
export const publicInterestSchema = z
|
|
|
|
|
.object({
|
|
|
|
|
// New: first/last split
|
|
|
|
|
firstName: z.string().min(1).max(100).optional(),
|
|
|
|
|
lastName: z.string().min(1).max(100).optional(),
|
|
|
|
|
// Backward compat
|
|
|
|
|
fullName: z.string().min(1).max(200).optional(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
phone: z.string().min(1),
|
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
|
|
|
/** Pre-normalized E.164 form, optional for backwards compat. */
|
|
|
|
|
phoneE164: optionalPhoneE164Schema.optional(),
|
|
|
|
|
/** ISO-3166-1 alpha-2 country the phone was parsed against. */
|
|
|
|
|
phoneCountry: optionalCountryIsoSchema.optional(),
|
|
|
|
|
/** ISO-3166-1 alpha-2 nationality. */
|
|
|
|
|
nationalityIso: optionalCountryIsoSchema.optional(),
|
2026-04-14 12:53:13 -04:00
|
|
|
preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(),
|
|
|
|
|
mooringNumber: z.string().max(50).optional(),
|
2026-04-24 15:42:45 +02:00
|
|
|
// NEW: required structured yacht block. Public submissions after the
|
|
|
|
|
// data-model refactor MUST include yacht data.
|
|
|
|
|
yacht: publicYachtSchema,
|
2026-05-04 22:57:01 +02:00
|
|
|
// NEW: optional company block - creates/upserts a company and adds a
|
2026-04-24 15:42:45 +02:00
|
|
|
// membership linking the submitting client to it.
|
|
|
|
|
company: publicCompanySchema.optional(),
|
|
|
|
|
source: z.literal('website').default('website'),
|
|
|
|
|
address: addressSchema.optional(),
|
2026-04-14 12:53:13 -04:00
|
|
|
})
|
|
|
|
|
.refine((data) => data.fullName || (data.firstName && data.lastName), {
|
|
|
|
|
message: 'Either fullName or both firstName and lastName are required',
|
|
|
|
|
path: ['fullName'],
|
|
|
|
|
});
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Reorder Waiting List ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const reorderWaitingListSchema = z.object({
|
|
|
|
|
entryId: z.string().min(1),
|
|
|
|
|
newPosition: z.coerce.number().int().min(1),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export type CreateInterestInput = z.infer<typeof createInterestSchema>;
|
|
|
|
|
export type UpdateInterestInput = z.infer<typeof updateInterestSchema>;
|
|
|
|
|
export type ChangeStageInput = z.infer<typeof changeStageSchema>;
|
|
|
|
|
export type ListInterestsInput = z.infer<typeof listInterestsSchema>;
|
|
|
|
|
export type WaitingListAddInput = z.infer<typeof waitingListAddSchema>;
|
|
|
|
|
export type PublicInterestInput = z.infer<typeof publicInterestSchema>;
|
|
|
|
|
export type ReorderWaitingListInput = z.infer<typeof reorderWaitingListSchema>;
|
2026-05-02 00:01:33 +02:00
|
|
|
export type SetOutcomeInput = z.infer<typeof setOutcomeSchema>;
|
|
|
|
|
export type ClearOutcomeInput = z.infer<typeof clearOutcomeSchema>;
|