diff --git a/docs/superpowers/audits/alpha-uat-master.md b/docs/superpowers/audits/alpha-uat-master.md index db86bdc4..562d0e29 100644 --- a/docs/superpowers/audits/alpha-uat-master.md +++ b/docs/superpowers/audits/alpha-uat-master.md @@ -688,6 +688,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._ 10. **Comprehensive admin-settings IA audit + regroup** — _src/app/(dashboard)/[portSlug]/admin/_ — 41 admin pages today, organically grown: `ai`, `audit`, `backup`, `berths/bulk-add`, `berths/reconcile`, `branding`, `brochures`, `custom-fields`, `documenso`, `duplicates`, `email-templates`, `email`, `errors`, `forms`, `import`, `inquiries`, `invitations`, `monitoring`, `ocr`, `onboarding`, `pipeline-rules`, `ports`, `pulse`, `qualification-criteria`, `reminders`, `reports`, `residential-stages`, `roles`, `sends`, `settings`, `storage`, `tags`, `templates`, `users`, `vocabularies`, `webhooks`, `website-analytics`. Settings are scattered — e.g. test-email lives on Branding, SMTP test on Email, password-reset copy probably in `email-templates`, but the rep has to guess. Audit each page for: (a) what settings live there now, (b) which settings logically belong elsewhere ("right home" test — Documenso send mode currently lives on Documenso, makes sense; per-port email signature would make more sense under Branding than Email), (c) duplicates (vocabularies vs custom-fields vs qualification-criteria overlap on enum tuning). Then propose a regrouped IA — likely fewer top-level pages with clear domain headers (Configuration → Branding, Email, Documenso, Storage, Webhooks; Workflows → Pipeline rules, Reminders, Auto-stage advancement; Catalog → Vocabularies, Tags, Custom fields, Qualification criteria; Operations → Monitoring, Pulse, Audit log, Errors, Backup; Data → Import, Duplicates, Bulk berth tools; Identity → Users, Roles, Invitations, Onboarding). Pair with a new admin index page that groups by domain instead of a flat alphabetical list. Effort: ~1.5-2 days — audit pass + IA proposal review + actual file moves + nav updates + redirect shims for old URLs. Captured 2026-05-22. - **SHIPPED in this session (Phase 1 + Phase 2):** Full audit + proposal at `docs/admin-ia-proposal.md`. Final IA = 7 domains, 38 pages (down from 41 via three deletes). `admin-sections-browser.tsx` rewritten to the new domain shape (Brand & Communication, Sales workflow, Catalog, Identity & access, Inbox & data quality, Integrations, System & observability). Deleted with redirects: `/admin/ocr` → `/admin/ai`, `/admin/reports` → `/[portSlug]/dashboard`, `/admin/invitations` → `/admin/users` (this last one was already a redirect). Renamed: "Documenso & EOI" → "Signing service (Documenso)". New: `/admin/berths` index page surfacing bulk-add + reconcile sub-tools (which were previously discoverable only via deep links). `` on Branding cross-links to `/admin/email` per-template tester. Search-nav-catalog updated (ocr entry removed, berths entry added). tsc clean. 11. **B3 #9 follow-up — UI wiring for universal upload-with-fields** — _src/components/documents/upload-for-signing-dialog.tsx_ (`` lives inside this monolith — needs extraction into a standalone component the other upload modals can mount conditionally), _src/components/documents/new-document-menu.tsx_ + _src/components/documents/documents-hub.tsx_ + _src/components/files/file-upload-zone.tsx_ + entity-tab upload sites (client/yacht/company doc tabs). **Backend foundations SHIPPED 2026-05-22**: `CustomDocumentType` union now includes `'generic'`; `uploadDocumentForSigning` skips pipeline-stage advance + doc-status flip when generic; route validator accepts the new value; storage path category routes to `signed-source/`. **UI half deferred** to a paired session — needs careful surgery to each upload modal to add the "Send for signature?" toggle + mount the extracted field-placement step. Effort for UI wiring: ~5-7h. Captured 2026-05-22. + - **SHIPPED in this session:** `UploadForSigningDialog` now accepts `interestId: string | null`, `entity?: { type, id }`, `folderId?` and `onCreated?` callback. When `interestId` is null + `documentType='generic'`, the dialog POSTs to a new generic endpoint `/api/v1/upload-for-signing` instead of the interest-scoped one. The service was refactored to accept `interestId: string | null` and an optional `entity` arg, skips the pipeline-stage advance + doc-status flip + interest lookup on the generic path, and routes the file row's FK + auto-filed folder via either the interest's client or the caller-supplied entity. New menu item in `NewDocumentMenu` ("Upload & send for signature") appears on both Documents Hub root + folder views; new buttons under `FileUploadZone` on `ClientFilesTab` + `CompanyFilesTab`. Permission gated by `documents.send_for_signing`. Service-level validation enforces the invariant that generic-type uploads MUST come without interestId and vice-versa. 12. **Time-period PDF report + chart rendering + deeper data** — _src/lib/pdf/reports/dashboard-report.tsx_, _src/lib/services/dashboard-report-data.service.ts_, _src/lib/pdf/reports/types.ts_, new _src/lib/pdf/reports/charts.tsx_, _src/components/reports/export-dashboard-pdf-button.tsx_ (date-range picker). Today's PDF report ignores dateFrom/dateTo for most sections and renders every chart-style widget as a table. User wants: (a) **time-range filter** that scopes EVERY section to a chosen window — new clients in the window, new interests in the window, active interests touching the window, in-progress berths (sold/under-offer transitions in the window), pipeline counts at the start vs end of window, etc.; (b) **chart rendering** — react-pdf supports SVG, so build small SVG generators (``, ``, ``) inline OR pre-render via vega-lite/d3-node to PNG and embed; (c) **deeper data per section** — add berths-in-flight (status changes within window), client+interest cohort tables, contact-cadence histogram, document-signing throughput. Shape: extend `DashboardReportData` with `window: {from, to}` and new sub-sections; extend the export-PDF dialog to take a date-range; route handler propagates the window to every per-section resolver. Effort: ~8-12h depending on chart-rendering approach (inline SVG is ~6h, vega-lite pre-render is ~10h with a worker round-trip). Captured 2026-05-22. - **SHIPPED in this session:** Catalog expanded from 5 ids to 25 — chart variants (pipeline funnel bar, berth status donut, source conversion bar, lead source donut, occupancy timeline line) + period cohorts (new clients/interests, berths sold, deposits received, documents/contracts signed) + value views (pipeline value breakdown, revenue forecast, avg sales cycle, berth demand, country distribution, deal pulse distribution, recent activity). Hand-rolled SVG chart primitives in `src/lib/pdf/reports/charts.tsx` (HorizontalBarChart, DonutChart, LineChart) using @react-pdf/renderer's native Svg/Path/Rect support. Export-dialog grew a date-range picker with Last-30/90-days quick presets, defaults to last 30 days. Route + service plumbing carries dateFrom/dateTo. 11 of 16 pending resolvers landed (new_clients_period, new_interests_period, berths_sold_period via audit log, deposits_received_period, signed_documents_period, contracts_signed_period, berth_demand_ranking, lead_source_donut, client_country_distribution, recent_activity, pipeline_value_breakdown, revenue_forecast, avg_sales_cycle). Still pending (in this session's PENDING_RESOLVER_IDS set): stage_conversion_rates, occupancy_timeline_chart (needs daily buckets), inquiry_inbox_summary, reminders_summary, deal_pulse_distribution (requires the pulse service's dynamic computation, not a simple column query — left as follow-up). Also shipped: PDF logo absolutize for server-side fetch (was empty because @react-pdf/renderer can't fetch path-only URLs server-side), "Dashboard report" → "Report" default name, section-orphan fix (`wrap={false}` + `minPresenceAhead`). diff --git a/src/app/api/v1/upload-for-signing/route.ts b/src/app/api/v1/upload-for-signing/route.ts new file mode 100644 index 00000000..e2ef8332 --- /dev/null +++ b/src/app/api/v1/upload-for-signing/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; +import { + uploadDocumentForSigning, + type CustomDocumentType, + type CustomRecipientRole, +} from '@/lib/services/custom-document-upload.service'; +import { isPdfMagic } from '@/lib/services/berth-pdf-parser'; + +/** + * Generic upload-for-signing endpoint — used by the Documents Hub and + * entity doc-tab "Send file for signature" buttons where the doc isn't + * tied to an interest's sales pipeline. The interest-scoped sibling + * (/api/v1/interests/[id]/upload-for-signing) is still the path for + * EOI / Contract / Reservation flows so the pipeline side effects fire. + * + * documentType is locked to 'generic' here. Optional `entity` + + * `folderId` route the file to the right place on the Documents Hub. + * + * Permission: documents.send_for_signing — same gate as the + * interest-scoped flow. + */ + +const recipientSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().email(), + role: z.enum(['SIGNER', 'APPROVER', 'CC']), + signingOrder: z.number().int().positive(), +}); + +const fieldSchema = z.object({ + recipientIndex: z.number().int().nonnegative(), + type: z.enum([ + 'SIGNATURE', + 'FREE_SIGNATURE', + 'INITIALS', + 'DATE', + 'EMAIL', + 'NAME', + 'TEXT', + 'NUMBER', + 'CHECKBOX', + 'DROPDOWN', + 'RADIO', + ]), + pageNumber: z.number().int().positive(), + pageX: z.number().min(0).max(100), + pageY: z.number().min(0).max(100), + pageWidth: z.number().positive().max(100), + pageHeight: z.number().positive().max(100), + fieldMeta: z.record(z.string(), z.unknown()).optional(), +}); + +const entitySchema = z.object({ + type: z.enum(['client', 'company', 'yacht']), + id: z.string().min(1), +}); + +const MAX_PDF_BYTES = 50 * 1024 * 1024; + +function parseJsonField(raw: unknown, schema: z.ZodType, label: string): T { + if (typeof raw !== 'string') { + throw new ValidationError(`Missing or non-string '${label}' field`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ValidationError(`'${label}' is not valid JSON`); + } + const result = schema.safeParse(parsed); + if (!result.success) { + throw new ValidationError(`'${label}' validation failed: ${result.error.issues[0]?.message}`); + } + return result.data; +} + +export const POST = withAuth( + withPermission('documents', 'send_for_signing', async (req, ctx) => { + try { + const form = await req.formData(); + + // ─── file ────────────────────────────────────────────────── + const file = form.get('file'); + if (!file || !(file instanceof File)) { + throw new ValidationError('Missing file'); + } + if (file.size > MAX_PDF_BYTES) { + throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`); + } + const buffer = Buffer.from(await file.arrayBuffer()); + if (!isPdfMagic(buffer)) { + throw new ValidationError('Uploaded file is not a PDF'); + } + + // ─── scalar fields ───────────────────────────────────────── + const title = z.string().min(1).max(255).parse(form.get('title')); + const invitationMessageRaw = form.get('invitationMessage'); + const invitationMessage = + typeof invitationMessageRaw === 'string' + ? z.string().max(1000).parse(invitationMessageRaw) + : null; + + // Optional entity / folder routing. + const entityRaw = form.get('entity'); + const entity = + typeof entityRaw === 'string' && entityRaw.length > 0 + ? parseJsonField(entityRaw, entitySchema, 'entity') + : null; + const folderIdRaw = form.get('folderId'); + const folderId = + typeof folderIdRaw === 'string' && folderIdRaw.length > 0 ? folderIdRaw : null; + + // ─── JSON fields ─────────────────────────────────────────── + const recipients = parseJsonField( + form.get('recipients'), + z.array(recipientSchema).min(1).max(20), + 'recipients', + ); + const fields = parseJsonField( + form.get('fields'), + z.array(fieldSchema).min(1).max(200), + 'fields', + ); + + const result = await uploadDocumentForSigning({ + interestId: null, + entity, + folderId, + portId: ctx.portId, + portSlug: ctx.portSlug, + documentType: 'generic' satisfies CustomDocumentType, + title, + pdfBuffer: buffer, + filename: file.name || 'document.pdf', + recipients: recipients.map((r) => ({ + name: r.name, + email: r.email, + role: r.role as CustomRecipientRole, + signingOrder: r.signingOrder, + })), + fields, + invitationMessage, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + + return NextResponse.json({ data: result }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/clients/client-files-tab.tsx b/src/components/clients/client-files-tab.tsx index 8d453fb6..cb6d30a2 100644 --- a/src/components/clients/client-files-tab.tsx +++ b/src/components/clients/client-files-tab.tsx @@ -2,11 +2,14 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { Pen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { FileGrid } from '@/components/files/file-grid'; import { FileUploadZone } from '@/components/files/file-upload-zone'; import { FilePreviewDialog } from '@/components/files/file-preview-dialog'; import { PermissionGate } from '@/components/shared/permission-gate'; +import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useConfirmation } from '@/hooks/use-confirmation'; @@ -21,6 +24,7 @@ interface ClientFilesTabProps { export function ClientFilesTab({ clientId }: ClientFilesTabProps) { const queryClient = useQueryClient(); const [previewFile, setPreviewFile] = useState(null); + const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false); const { confirm, dialog: confirmDialog } = useConfirmation(); const { data, isLoading } = usePaginatedQuery({ @@ -64,12 +68,28 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) { return (
- { - queryClient.invalidateQueries({ queryKey: ['files', { clientId }] }); - }} - /> +
+ { + queryClient.invalidateQueries({ queryKey: ['files', { clientId }] }); + }} + /> + +
+ +
+
+
+ + {uploadForSigningOpen && ( + + )} + {confirmDialog}
); diff --git a/src/components/companies/company-files-tab.tsx b/src/components/companies/company-files-tab.tsx index 441f3ee5..95924e0b 100644 --- a/src/components/companies/company-files-tab.tsx +++ b/src/components/companies/company-files-tab.tsx @@ -2,11 +2,14 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { Pen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { FileGrid } from '@/components/files/file-grid'; import { FileUploadZone } from '@/components/files/file-upload-zone'; import { FilePreviewDialog } from '@/components/files/file-preview-dialog'; import { PermissionGate } from '@/components/shared/permission-gate'; +import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useConfirmation } from '@/hooks/use-confirmation'; @@ -21,6 +24,7 @@ interface CompanyFilesTabProps { export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) { const queryClient = useQueryClient(); const [previewFile, setPreviewFile] = useState(null); + const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false); const { confirm, dialog: confirmDialog } = useConfirmation(); const { data, isLoading } = usePaginatedQuery({ @@ -64,12 +68,28 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) { return (
- { - queryClient.invalidateQueries({ queryKey: ['files', { companyId }] }); - }} - /> +
+ { + queryClient.invalidateQueries({ queryKey: ['files', { companyId }] }); + }} + /> + +
+ +
+
+
+ + {uploadForSigningOpen && ( + + )} + {confirmDialog}
); diff --git a/src/components/documents/new-document-menu.tsx b/src/components/documents/new-document-menu.tsx index 2f83c700..4ffbf68e 100644 --- a/src/components/documents/new-document-menu.tsx +++ b/src/components/documents/new-document-menu.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { useQueryClient } from '@tanstack/react-query'; -import { ChevronDown, FileSignature, Plus, Upload } from 'lucide-react'; +import { ChevronDown, FileSignature, Pen, Plus, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -20,6 +20,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { FileUploadZone } from '@/components/files/file-upload-zone'; +import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog'; /** * Dropdown that replaces the bare "+ New document" button on the documents @@ -55,6 +56,7 @@ export function NewDocumentMenu({ size = 'default', }: NewDocumentMenuProps) { const [uploadOpen, setUploadOpen] = useState(false); + const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false); const queryClient = useQueryClient(); return ( @@ -77,6 +79,15 @@ export function NewDocumentMenu({ + setUploadForSigningOpen(true)} className="gap-2 py-2.5"> + +
+ Upload & send for signature + + Drop a PDF, place fields, send via Documenso + +
+
@@ -123,6 +134,17 @@ export function NewDocumentMenu({ /> + + {uploadForSigningOpen && ( + + )} ); } diff --git a/src/components/documents/upload-for-signing-dialog.tsx b/src/components/documents/upload-for-signing-dialog.tsx index 79a42977..8f708098 100644 --- a/src/components/documents/upload-for-signing-dialog.tsx +++ b/src/components/documents/upload-for-signing-dialog.tsx @@ -139,17 +139,37 @@ const RECIPIENT_COLORS = [ 'rgb(20 184 166)', // teal-500 ]; +export interface UploadForSigningEntity { + type: 'client' | 'company' | 'yacht'; + id: string; + /** Display label only — used in the dialog header so the rep can + * see which entity the doc will be filed under. */ + label?: string; +} + interface UploadForSigningDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - interestId: string; - /** Pre-set the document type - the parent (EOI/Contract/Reservation - * tab) decides which to upload. EOI here is the upload-draft path; - * the template-driven generate flow lives on EoiGenerateDialog. */ - documentType: 'eoi' | 'contract' | 'reservation_agreement'; + /** Required for eoi / contract / reservation_agreement (the pipeline + * side effects need it). MUST be null for documentType='generic' — + * in that case the upload routes through the generic endpoint and + * optionally files the doc against the supplied `entity`. */ + interestId: string | null; + documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic'; /** Optional: client name/email to prefill the first recipient. - * When omitted the dialog fetches from the interest. */ + * When omitted the dialog fetches from the interest (interest-scoped + * flows) or leaves the recipient blank (generic flow). */ clientPrefill?: { name: string; email: string }; + /** Generic flow only: routes the resulting file/document row to the + * entity's FK column + auto-files it into the entity's system + * folder. Ignored when `interestId` is set. */ + entity?: UploadForSigningEntity; + /** Generic flow only: explicit folder placement (e.g. rep is + * uploading from within a Documents Hub folder). */ + folderId?: string | null; + /** Generic flow only: caller-supplied success hook. Receives the + * new documentId and can invalidate caches / show a toast. */ + onCreated?: (result: { documentId: string }) => void; } export function UploadForSigningDialog({ @@ -158,18 +178,25 @@ export function UploadForSigningDialog({ interestId, documentType, clientPrefill, + entity, + folderId, + onCreated, }: UploadForSigningDialogProps) { // Re-mount the body on every open so all state resets cleanly. Same // pattern as hard-delete-dialog (set-state-in-effect avoidance). if (!open) return null; + const draftKey = interestId ?? entity?.id ?? 'generic'; return ( onOpenChange(false)} /> @@ -186,8 +213,8 @@ type Step = 'select-file' | 'configure-recipients' | 'place-fields'; * contract upload AND reservation upload in the same browser session * without them clobbering each other. */ -function draftStorageKey(interestId: string, documentType: string): string { - return `pn-crm.upload-for-signing.draft.v1:${interestId}:${documentType}`; +function draftStorageKey(scopeId: string, documentType: string): string { + return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`; } interface PersistedDraft { @@ -200,10 +227,10 @@ interface PersistedDraft { savedAt: string; } -function loadDraft(interestId: string, documentType: string): PersistedDraft | null { +function loadDraft(scopeId: string, documentType: string): PersistedDraft | null { if (typeof window === 'undefined') return null; try { - const raw = window.localStorage.getItem(draftStorageKey(interestId, documentType)); + const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType)); if (!raw) return null; const parsed = JSON.parse(raw) as PersistedDraft; // Defensive shape check - drop drafts that look malformed rather @@ -221,19 +248,19 @@ function loadDraft(interestId: string, documentType: string): PersistedDraft | n } } -function saveDraft(interestId: string, documentType: string, draft: PersistedDraft): void { +function saveDraft(scopeId: string, documentType: string, draft: PersistedDraft): void { if (typeof window === 'undefined') return; try { - window.localStorage.setItem(draftStorageKey(interestId, documentType), JSON.stringify(draft)); + window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft)); } catch { // localStorage may throw on private mode or quota - swallow. } } -function clearDraft(interestId: string, documentType: string): void { +function clearDraft(scopeId: string, documentType: string): void { if (typeof window === 'undefined') return; try { - window.localStorage.removeItem(draftStorageKey(interestId, documentType)); + window.localStorage.removeItem(draftStorageKey(scopeId, documentType)); } catch { // ignore } @@ -243,19 +270,30 @@ function DialogBody({ interestId, documentType, clientPrefill, + entity, + folderId, + onCreated, onClose, }: { - interestId: string; - documentType: 'eoi' | 'contract' | 'reservation_agreement'; + interestId: string | null; + documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic'; clientPrefill?: { name: string; email: string }; + entity?: UploadForSigningEntity; + folderId?: string | null; + onCreated?: (result: { documentId: string }) => void; onClose: () => void; }) { + // Draft scope: interestId when scoped to a deal, otherwise the + // entity id (so the rep can have one in-flight upload per entity), + // else 'generic' for the root Documents Hub flow. + const draftScopeId = interestId ?? entity?.id ?? 'generic'; + // Hydrate from the persisted draft once on mount. The `key` prop on // the parent re-mounts this body on every open, so this useState // initializer runs once per dialog session. const initialDraft = useMemo( - () => loadDraft(interestId, documentType), - [interestId, documentType], + () => loadDraft(draftScopeId, documentType), + [draftScopeId, documentType], ); const [step, setStep] = useState(initialDraft?.step ?? 'select-file'); @@ -275,7 +313,9 @@ function DialogBody({ ? 'Sales Contract' : documentType === 'eoi' ? 'Expression of Interest' - : 'Reservation Agreement'; + : documentType === 'reservation_agreement' + ? 'Reservation Agreement' + : 'Document'; // Defaults endpoint - drives the developer/approver prefill. const { data: defaults } = useQuery<{ data: SigningDefaults }>({ @@ -285,7 +325,7 @@ function DialogBody({ // Interest endpoint - used to prefill the client recipient when the // caller didn't supply one. Cached so the same dialog open/reopen - // hits the cache. + // hits the cache. Skipped entirely on the generic path (no interest). const { data: interestData } = useQuery<{ data: { client: { fullName: string; email: string | null } }; }>({ @@ -294,7 +334,7 @@ function DialogBody({ apiFetch<{ data: { client: { fullName: string; email: string | null } } }>( `/api/v1/interests/${interestId}`, ), - enabled: !clientPrefill, + enabled: Boolean(interestId) && !clientPrefill, }); /** @@ -381,11 +421,11 @@ function DialogBody({ fields.length > 0 || invitationMessage.length > 0; if (!hasProgress) { - clearDraft(interestId, documentType); + clearDraft(draftScopeId, documentType); return; } const now = new Date().toISOString(); - saveDraft(interestId, documentType, { + saveDraft(draftScopeId, documentType, { step, title, recipients, @@ -399,10 +439,10 @@ function DialogBody({ return () => { if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current); }; - }, [step, title, recipients, fields, invitationMessage, interestId, documentType]); + }, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]); function discardDraft() { - clearDraft(interestId, documentType); + clearDraft(draftScopeId, documentType); setTitle(''); setRecipients([]); setFields([]); @@ -479,7 +519,23 @@ function DialogBody({ })), ), ); - const res = await fetch(`/api/v1/interests/${interestId}/upload-for-signing`, { + // Generic envelopes go to the cross-cutting endpoint; the + // entity / folder context piggybacks on the form so the file + // row lands under the right system folder. Interest-scoped + // flows keep their dedicated route so the pipeline-stage + // advance + doc-status flip side effects fire. + if (interestId) { + if (documentType === 'generic') { + throw new Error('Generic documentType requires interestId=null'); + } + } else { + if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id })); + if (folderId) form.append('folderId', folderId); + } + const endpoint = interestId + ? `/api/v1/interests/${interestId}/upload-for-signing` + : `/api/v1/upload-for-signing`; + const res = await fetch(endpoint, { method: 'POST', body: form, credentials: 'include', @@ -506,11 +562,14 @@ function DialogBody({ ); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' }); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' }); - void res; + queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' }); + if (onCreated && res?.data?.documentId) { + onCreated({ documentId: res.data.documentId }); + } // Clear the draft on successful submission - the in-flight upload // is now an actual document; the localStorage shouldn't keep its // shadow around. - clearDraft(interestId, documentType); + clearDraft(draftScopeId, documentType); onClose(); }, onError: (err) => toastError(err, 'Upload failed'), diff --git a/src/lib/services/custom-document-upload.service.ts b/src/lib/services/custom-document-upload.service.ts index 5eba8120..d4213983 100644 --- a/src/lib/services/custom-document-upload.service.ts +++ b/src/lib/services/custom-document-upload.service.ts @@ -87,7 +87,20 @@ export interface CustomDocumentRecipient { } export interface UploadDocumentForSigningArgs { - interestId: string; + /** Optional interest the doc is filed under. Required for eoi / + * contract / reservation_agreement (their pipeline-stage side + * effects need it); MUST be null for 'generic' (cross-cutting + * envelopes that aren't tied to a sales deal). */ + interestId: string | null; + /** Optional entity context — drives the auto-filed folder + the + * file-row FK. Used by the 'generic' path when there's no interest + * to derive the client from. Ignored when `interestId` is set + * (the service resolves the client off the interest itself). */ + entity?: { type: 'client' | 'company' | 'yacht'; id: string } | null; + /** Optional explicit folder placement. When set, overrides the + * entity-derived folder (e.g. rep dropped the upload into a + * specific subfolder from the Documents Hub). */ + folderId?: string | null; portId: string; portSlug: string; documentType: CustomDocumentType; @@ -125,6 +138,8 @@ export async function uploadDocumentForSigning( ): Promise { const { interestId, + entity, + folderId: explicitFolderId, portId, portSlug, documentType, @@ -137,6 +152,21 @@ export async function uploadDocumentForSigning( meta, } = args; + // Generic envelopes (no pipeline-stage advance / no interest) MUST + // come in with interestId=null; non-generic types MUST carry an + // interest. Reject the mismatch here so the rest of the function can + // assume the right invariant. + if (documentType !== 'generic' && !interestId) { + throw new ValidationError( + `${documentType} document requires an interestId — only 'generic' documents can be uploaded without one`, + ); + } + if (documentType === 'generic' && interestId) { + throw new ValidationError( + 'Generic documents cannot carry an interestId — use a type-specific document type instead', + ); + } + // ─── Validation ────────────────────────────────────────────────── if (recipients.length === 0) { throw new ValidationError('At least one recipient is required'); @@ -175,10 +205,15 @@ export async function uploadDocumentForSigning( } // ─── Tenant guard ──────────────────────────────────────────────── - const interest = await db.query.interests.findFirst({ - where: and(eq(interests.id, interestId), eq(interests.portId, portId)), - }); - if (!interest) throw new NotFoundError('Interest'); + // Non-generic types resolve their interest (and derive the client + // from there). Generic types skip the interest lookup; entity FK + // routing comes from the caller-supplied `entity` arg. + const interest = interestId + ? await db.query.interests.findFirst({ + where: and(eq(interests.id, interestId), eq(interests.portId, portId)), + }) + : null; + if (interestId && !interest) throw new NotFoundError('Interest'); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); @@ -200,10 +235,14 @@ export async function uploadDocumentForSigning( : documentType === 'eoi' ? 'eoi-source' : 'signed-source'; + // Storage path groups by interestId when we have one; for generic + // uploads the entity id (or a synthetic 'unfiled' bucket) keeps the + // namespace tidy. + const storageGroupId = interestId ?? entity?.id ?? 'unfiled'; const sourceStoragePath = buildStoragePath( portSlug, storageCategory, - interestId, + storageGroupId, sourceFileId, 'pdf', ); @@ -214,11 +253,16 @@ export async function uploadDocumentForSigning( sizeBytes: pdfBuffer.length, }); - // Look up the interest's primary client so the auto-filed folder - // ends up under the right entity subfolder. Falls back to root when - // the chain has no resolvable owner. - let entityFolderId: string | null = null; - if (interest.clientId) { + // Folder placement priority: + // 1. Caller-supplied `folderId` (rep dropped the upload into a + // specific Documents Hub folder). + // 2. Interest's primary client folder (legacy path for + // EOI/contract/reservation tabs). + // 3. Caller-supplied entity (generic path: client/company/yacht + // doc tab originated the upload). + // 4. Root (fallback). + let entityFolderId: string | null = explicitFolderId ?? null; + if (entityFolderId === null && interest?.clientId) { try { const folder = await ensureEntityFolder(portId, 'client', interest.clientId, 'system'); entityFolderId = folder.id; @@ -229,12 +273,38 @@ export async function uploadDocumentForSigning( ); } } + if (entityFolderId === null && entity) { + try { + const folder = await ensureEntityFolder(portId, entity.type, entity.id, 'system'); + entityFolderId = folder.id; + } catch (err) { + logger.warn( + { err, entity }, + 'ensureEntityFolder failed for generic upload entity - filing at root', + ); + } + } + + // Derive the entity-FK fields on the `files` row from whichever + // source we have. Interest-derived takes priority; otherwise the + // generic `entity` arg maps to its corresponding column. + const fileEntityFKs: { + clientId: string | null; + companyId: string | null; + yachtId: string | null; + } = { + clientId: interest?.clientId ?? (entity?.type === 'client' ? entity.id : null), + companyId: entity?.type === 'company' ? entity.id : null, + yachtId: entity?.type === 'yacht' ? entity.id : null, + }; const [sourceFileRecord] = await db .insert(files) .values({ portId, - clientId: interest.clientId, + clientId: fileEntityFKs.clientId, + companyId: fileEntityFKs.companyId, + yachtId: fileEntityFKs.yachtId, folderId: entityFolderId, filename, originalName: filename, @@ -259,7 +329,9 @@ export async function uploadDocumentForSigning( .values({ portId, interestId, - clientId: interest.clientId, + clientId: fileEntityFKs.clientId, + companyId: fileEntityFKs.companyId, + yachtId: fileEntityFKs.yachtId, fileId: sourceFileRecord.id, documentType, title, @@ -412,7 +484,7 @@ export async function uploadDocumentForSigning( // per-type doc-status flip - they're cross-cutting envelopes that // happen to be filed against this interest. The eoi / contract / // reservation_agreement branches keep their existing side effects. - if (documentType !== 'generic') { + if (documentType !== 'generic' && interestId) { const stageByType: Record< Exclude, 'eoi' | 'contract' | 'reservation'