Files
pn-new-crm/src/lib/validators/documents.ts
Matt Ciaccio f52d21df83 feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00

107 lines
3.6 KiB
TypeScript

import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { DOCUMENT_TYPES, DOCUMENT_STATUSES } from '@/lib/constants';
export const createDocumentSchema = z.object({
interestId: z.string().optional(),
clientId: z.string().optional(),
documentType: z.enum(DOCUMENT_TYPES),
title: z.string().min(1).max(200),
notes: z.string().optional(),
});
export const updateDocumentSchema = z.object({
title: z.string().min(1).max(200).optional(),
notes: z.string().optional(),
status: z.enum(DOCUMENT_STATUSES).optional(),
});
const wizardSignerSchema = z.object({
signerName: z.string().min(1),
signerEmail: z.string().email(),
signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
signingOrder: z.number().int().min(1),
});
export const createDocumentWizardSchema = z
.object({
source: z.enum(['template', 'upload']).default('template'),
templateId: z.string().optional(),
uploadedFileId: z.string().optional(),
documentType: z.enum(DOCUMENT_TYPES),
title: z.string().min(1).max(200),
notes: z.string().optional(),
interestId: z.string().optional(),
reservationId: z.string().optional(),
clientId: z.string().optional(),
companyId: z.string().optional(),
yachtId: z.string().optional(),
signers: z.array(wizardSignerSchema).optional(),
signingMode: z.enum(['sequential', 'parallel']).default('sequential'),
pathway: z.enum(['documenso-template', 'inapp', 'upload']).default('documenso-template'),
watchers: z.array(z.string()).default([]),
reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
remindersDisabled: z.boolean().default(false),
autoPlaceFields: z.boolean().default(true),
sendImmediately: z.boolean().default(true),
})
.refine(
(d) =>
[d.interestId, d.reservationId, d.clientId, d.companyId, d.yachtId].filter(Boolean).length ===
1,
{ message: 'Exactly one subject (interest/reservation/client/company/yacht) is required' },
)
.refine((d) => d.source !== 'template' || Boolean(d.templateId), {
path: ['templateId'],
message: 'templateId is required when source=template',
})
.refine((d) => d.source !== 'upload' || Boolean(d.uploadedFileId), {
path: ['uploadedFileId'],
message: 'uploadedFileId is required when source=upload',
});
export type CreateDocumentWizardInput = z.infer<typeof createDocumentWizardSchema>;
export const documentsHubTabs = [
'all',
'eoi_queue',
'awaiting_them',
'awaiting_me',
'completed',
'expired',
] as const;
export type DocumentsHubTab = (typeof documentsHubTabs)[number];
export const listDocumentsSchema = baseListQuerySchema.extend({
interestId: z.string().optional(),
clientId: z.string().optional(),
documentType: z.string().optional(),
status: z.string().optional(),
/** Hub tab filter — applies tab-specific status / signer-membership constraints. */
tab: z.enum(documentsHubTabs).optional(),
/** Restrict to docs being watched by this user id. */
watcherUserId: z.string().optional(),
/** When true, only docs intended for signing (default true on hub). */
signatureOnly: z
.enum(['true', 'false'])
.optional()
.transform((v) => (v === undefined ? undefined : v === 'true')),
sentSince: z.string().datetime().optional(),
sentUntil: z.string().datetime().optional(),
});
export const uploadSignedSchema = z.object({
documentId: z.string().min(1),
});
export type CreateDocumentInput = z.infer<typeof createDocumentSchema>;
export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>;
export type ListDocumentsInput = z.infer<typeof listDocumentsSchema>;