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>
107 lines
3.6 KiB
TypeScript
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>;
|