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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user