feat(documents): universal upload-with-fields UI wiring (B3 #11)

Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.

- UploadForSigningDialog: interestId now string | null; new entity?,
  folderId?, onCreated? props. Generic path POSTs to new endpoint
  /api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
  lookup, pipeline-stage advance, doc-status flip on the generic path.
  Routes file FK + auto-filed folder via either interest.clientId or the
  caller-supplied entity. Validation enforces the matching invariant
  (generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
  Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
  gated by documents.send_for_signing.

Existing unit tests for the service still pass (validation paths unchanged).
This commit is contained in:
2026-05-23 01:01:52 +02:00
parent 221ae5784e
commit 5bd0e1ad9a
7 changed files with 432 additions and 56 deletions

View File

@@ -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<UploadDocumentForSigningResult> {
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<CustomDocumentType, 'generic'>,
'eoi' | 'contract' | 'reservation'