Files
pn-new-crm/src/lib/validators/interests.ts

218 lines
9.2 KiB
TypeScript
Raw Normal View History

import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
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';
// ─── Create ──────────────────────────────────────────────────────────────────
/**
* 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);
});
export const createInterestSchema = z.object({
clientId: z.string().min(1),
yachtId: z.string().optional(),
berthId: z.string().optional(),
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
source: z.string().optional(),
notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
reminderEnabled: z.boolean().optional().default(false),
reminderDays: z.number().int().min(1).optional(),
desiredLengthFt: optionalDesiredDimSchema,
desiredWidthFt: optionalDesiredDimSchema,
desiredDraftFt: optionalDesiredDimSchema,
});
// ─── 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(),
});
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
// ─── Outcome (Won / Lost) ─────────────────────────────────────────────────────
export const INTEREST_OUTCOMES = [
'won',
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'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(),
});
// ─── List ─────────────────────────────────────────────────────────────────────
export const listInterestsSchema = baseListQuerySchema.extend({
clientId: z.string().optional(),
yachtId: z.string().optional(),
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(),
});
// ─── 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 ──────────────────────────────────────────────────────────
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(),
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(),
});
// 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(),
role: z
.enum([
'director',
'officer',
'broker',
'representative',
'legal_counsel',
'employee',
'shareholder',
'other',
])
.optional()
.default('representative'),
});
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(),
preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(),
mooringNumber: z.string().max(50).optional(),
// NEW: required structured yacht block. Public submissions after the
// data-model refactor MUST include yacht data.
yacht: publicYachtSchema,
// NEW: optional company block - creates/upserts a company and adds a
// membership linking the submitting client to it.
company: publicCompanySchema.optional(),
source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(),
address: addressSchema.optional(),
})
.refine((data) => data.fullName || (data.firstName && data.lastName), {
message: 'Either fullName or both firstName and lastName are required',
path: ['fullName'],
});
// ─── 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>;
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
export type SetOutcomeInput = z.infer<typeof setOutcomeSchema>;
export type ClearOutcomeInput = z.infer<typeof clearOutcomeSchema>;