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:
2026-05-14 15:42:21 +02:00
parent a77b3c670a
commit 4182652d49
5 changed files with 306 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { markExternallySigned } from '@/lib/services/external-signing.service';
const bodySchema = z.object({
docType: z.enum(['eoi', 'reservation', 'contract']),
reason: z.string().trim().max(2000).optional(),
});
/**
* POST /api/v1/interests/[id]/mark-externally-signed
*
* Marks the named document type as signed without requiring a file
* upload. Sets the relevant `*_doc_status` column to 'signed', writes
* an audit log entry capturing the reason, and fires the appropriate
* berth-rule trigger (eoi_signed / contract_signed) if any.
*/
export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const interestId = params.id;
if (!interestId) throw new NotFoundError('Interest');
const input = await parseBody(req, bodySchema);
const result = await markExternallySigned(
{
interestId,
portId: ctx.portId,
docType: input.docType,
reason: input.reason ?? null,
},
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);