feat(externally-signed): mark contract/reservation as signed without file
Step 4 second slice. Adds the "Mark as signed without file" action to
contract + reservation tabs per PRE-DEPLOY-PLAN § 1.5.14.
Service: `markExternallySigned(interestId, portId, docType, reason)`
flips the relevant doc-status column ('contract_doc_status' /
'reservation_doc_status' / 'eoi_doc_status') to 'signed', writes an
audit log entry with `metadata.type='externally_signed'` capturing
the optional reason, and fires the appropriate berth-rule trigger
(eoi_signed / contract_signed) so downstream automation (berth
status flips, notifications) treats it identically to a Documenso-
signed completion.
Route: POST /api/v1/interests/[id]/mark-externally-signed gated on
interests.edit. Validates docType against the canonical 3-value enum.
UI: <MarkExternallySignedDialog> AlertDialog with optional reason
textarea + per-docType copy. Wired into EmptyContractState and
EmptyReservationState empty-state buttons. The action sits alongside
"Upload draft for signing" and "Upload paper-signed copy" as a third
option for reps whose canonical paper lives elsewhere.
EOI not yet wired into a UI surface — the eoi flow already has a
full upload pipeline. Service supports it for completeness.
Followup: quick brochure/PDF download buttons + per-user reminder
digest schedule still pending in Step 4 backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
98
src/lib/services/external-signing.service.ts
Normal file
98
src/lib/services/external-signing.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* "Mark as signed externally" service — records that a contract /
|
||||
* reservation / EOI was signed outside the CRM without requiring the
|
||||
* rep to upload a file. The interest's doc-status flips to 'signed'
|
||||
* and an audit-log entry captures the optional reason ("paper signed
|
||||
* in-office", "scanned and emailed later", etc.).
|
||||
*
|
||||
* Distinct from `uploadExternallySignedEoi` which requires a PDF.
|
||||
* Reps reach for this when the original lives elsewhere (a physical
|
||||
* folder, the client's email, another system) and they don't want to
|
||||
* duplicate-store it just to flip the CRM forward.
|
||||
*
|
||||
* The UI surfaces a warning banner on the relevant tab: "No file on
|
||||
* record — signed externally". Reps can later upload the file via
|
||||
* the existing external-upload dialog if a copy turns up.
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { evaluateRule } from '@/lib/services/berth-rules-engine';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
|
||||
export type ExternalSignedDocType = 'eoi' | 'reservation' | 'contract';
|
||||
|
||||
const STATUS_COLUMNS: Record<
|
||||
ExternalSignedDocType,
|
||||
'eoiDocStatus' | 'reservationDocStatus' | 'contractDocStatus'
|
||||
> = {
|
||||
eoi: 'eoiDocStatus',
|
||||
reservation: 'reservationDocStatus',
|
||||
contract: 'contractDocStatus',
|
||||
};
|
||||
|
||||
const BERTH_RULE_TRIGGERS: Record<ExternalSignedDocType, 'eoi_signed' | 'contract_signed' | null> =
|
||||
{
|
||||
eoi: 'eoi_signed',
|
||||
reservation: null,
|
||||
contract: 'contract_signed',
|
||||
};
|
||||
|
||||
export interface MarkExternallySignedInput {
|
||||
interestId: string;
|
||||
portId: string;
|
||||
docType: ExternalSignedDocType;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export async function markExternallySigned(
|
||||
input: MarkExternallySignedInput,
|
||||
meta: AuditMeta,
|
||||
): Promise<{ docType: ExternalSignedDocType; previousStatus: string | null }> {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, input.interestId), eq(interests.portId, input.portId)),
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
const column = STATUS_COLUMNS[input.docType];
|
||||
const previousStatus = (interest[column] ?? null) as string | null;
|
||||
if (previousStatus === 'signed') {
|
||||
throw new ValidationError(
|
||||
`${input.docType} is already marked as signed for this interest. Use the upload flow to attach a file.`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ [column]: 'signed', updatedAt: now })
|
||||
.where(and(eq(interests.id, input.interestId), eq(interests.portId, input.portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId: input.portId,
|
||||
action: 'update',
|
||||
entityType: 'interest',
|
||||
entityId: input.interestId,
|
||||
oldValue: { [column]: previousStatus },
|
||||
newValue: { [column]: 'signed', reason: input.reason ?? null },
|
||||
metadata: { type: 'externally_signed', docType: input.docType, withoutFile: true },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${input.portId}`, 'interest:updated', {
|
||||
interestId: input.interestId,
|
||||
changedFields: [column, 'updatedAt'],
|
||||
});
|
||||
|
||||
const trigger = BERTH_RULE_TRIGGERS[input.docType];
|
||||
if (trigger) {
|
||||
void evaluateRule(trigger, input.interestId, input.portId, meta);
|
||||
}
|
||||
|
||||
return { docType: input.docType, previousStatus };
|
||||
}
|
||||
Reference in New Issue
Block a user