import { z } from 'zod'; import { baseListQuerySchema } from '@/lib/api/list-query'; import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants'; 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(), tagIds: z.array(z.string()).optional().default([]), // 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(), 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(), /** 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(), }); // ─── 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(), }); // ─── 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; // ─── 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(), /** ISO 3166-2 subdivision code (e.g. 'PL-MZ'). */ subdivisionIso: optionalSubdivisionIsoSchema.optional(), postalCode: z.string().max(50).optional(), /** 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(), /** 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), /** 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'), 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; export type UpdateInterestInput = z.infer; export type ChangeStageInput = z.infer; export type ListInterestsInput = z.infer; export type WaitingListAddInput = z.infer; export type PublicInterestInput = z.infer; export type ReorderWaitingListInput = z.infer; export type SetOutcomeInput = z.infer; export type ClearOutcomeInput = z.infer;