From 877ad66ead92ccdcded6ed6e16937979ac2861c8 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 20 Mar 2026 11:29:54 -0600 Subject: [PATCH] feat(06-02): POST /api/documents/[id]/send + document_prepared audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add send/route.ts: creates signing token, sends branded email, logs email_sent, updates status to Sent - Auth guard returns 401 for unauthenticated requests; 422 if not prepared; 409 if already signed - Wraps sendMail in try/catch — returns 502 without DB update if email delivery fails - Add logAuditEvent(document_prepared) to prepare/route.ts after successful PDF preparation --- .../app/api/documents/[id]/prepare/route.ts | 3 + .../src/app/api/documents/[id]/send/route.ts | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts diff --git a/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts b/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts index 98763ad..c0c477c 100644 --- a/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts +++ b/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db'; import { documents } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import { preparePdf } from '@/lib/pdf/prepare-document'; +import { logAuditEvent } from '@/lib/signing/audit'; import path from 'node:path'; const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); @@ -54,5 +55,7 @@ export async function POST( .where(eq(documents.id, id)) .returning(); + await logAuditEvent({ documentId: id, eventType: 'document_prepared' }); + return Response.json(updated); } diff --git a/teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts b/teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts new file mode 100644 index 0000000..530ac4e --- /dev/null +++ b/teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { documents, clients } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { createSigningToken } from '@/lib/signing/token'; +import { sendSigningRequestEmail } from '@/lib/signing/signing-mailer'; +import { logAuditEvent } from '@/lib/signing/audit'; + +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session) return new Response('Unauthorized', { status: 401 }); + + const { id } = await params; + + try { + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, id), + }); + if (!doc) return NextResponse.json({ error: 'Document not found' }, { status: 404 }); + if (!doc.preparedFilePath) + return NextResponse.json({ error: 'Document not yet prepared' }, { status: 422 }); + if (doc.status === 'Signed') + return NextResponse.json({ error: 'Already signed' }, { status: 409 }); + + // Resolve recipient: prefer assignedClientId, fall back to clientId + const clientId = doc.assignedClientId ?? doc.clientId; + const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) }); + if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 422 }); + + const { token, expiresAt } = await createSigningToken(doc.id); + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'; + const signingUrl = `${baseUrl}/sign/${token}`; + + await sendSigningRequestEmail({ + to: client.email, + clientName: client.name, + documentName: doc.name, + signingUrl, + expiresAt, + }); + + await logAuditEvent({ documentId: doc.id, eventType: 'email_sent' }); + + // Update status to Sent (skip if already Sent or Signed to avoid downgrade) + if (doc.status === 'Draft') { + await db + .update(documents) + .set({ status: 'Sent', sentAt: new Date() }) + .where(eq(documents.id, id)); + } + + return NextResponse.json({ ok: true, expiresAt: expiresAt.toISOString() }); + } catch (err) { + console.error('[send] error:', err); + return NextResponse.json({ error: 'Failed to send signing email' }, { status: 502 }); + } +}