From e2bda51d91f1ce00f6b0f026b5aa7d89b0b07490 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 3 Apr 2026 18:21:07 -0600 Subject: [PATCH] feat: per-signer status panel with Resend button on sent documents, fix sign page field filter --- .../app/api/documents/[id]/resend/route.ts | 53 +++++++++++++ .../app/api/documents/[id]/signers/route.ts | 35 +++++++++ .../[docId]/_components/PreparePanel.tsx | 76 +++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 teressa-copeland-homes/src/app/api/documents/[id]/resend/route.ts create mode 100644 teressa-copeland-homes/src/app/api/documents/[id]/signers/route.ts diff --git a/teressa-copeland-homes/src/app/api/documents/[id]/resend/route.ts b/teressa-copeland-homes/src/app/api/documents/[id]/resend/route.ts new file mode 100644 index 0000000..5c1ec7d --- /dev/null +++ b/teressa-copeland-homes/src/app/api/documents/[id]/resend/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { documents, signingTokens } from '@/lib/db/schema'; +import type { DocumentSigner } 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'; + +// POST /api/documents/[id]/resend?signerEmail=... — resend signing link to one signer +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; + const url = new URL(req.url); + const signerEmail = url.searchParams.get('signerEmail'); + if (!signerEmail) return NextResponse.json({ error: 'signerEmail required' }, { status: 400 }); + + const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) }); + if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + if (doc.status === 'Signed') return NextResponse.json({ error: 'Document already fully signed' }, { status: 409 }); + if (!doc.preparedFilePath) return NextResponse.json({ error: 'Document not prepared' }, { status: 422 }); + + // Verify this signer is on the document + const signers = (doc.signers ?? []) as DocumentSigner[]; + const signer = signers.find(s => s.email === signerEmail); + if (!signer) return NextResponse.json({ error: 'Signer not found on document' }, { status: 404 }); + + // Simply create a new token and resend — old unused tokens will still work too + const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000'; + const { token, expiresAt } = await createSigningToken(id, signerEmail); + const signingUrl = `${baseUrl}/sign/${token}`; + + await sendSigningRequestEmail({ + to: signerEmail, + documentName: doc.name, + signingUrl, + expiresAt, + }); + + await logAuditEvent({ + documentId: id, + eventType: 'email_sent', + metadata: { signerEmail, resent: true }, + }); + + return NextResponse.json({ ok: true, expiresAt: expiresAt.toISOString() }); +} diff --git a/teressa-copeland-homes/src/app/api/documents/[id]/signers/route.ts b/teressa-copeland-homes/src/app/api/documents/[id]/signers/route.ts new file mode 100644 index 0000000..f1acb5b --- /dev/null +++ b/teressa-copeland-homes/src/app/api/documents/[id]/signers/route.ts @@ -0,0 +1,35 @@ +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { documents, signingTokens } from '@/lib/db/schema'; +import type { DocumentSigner } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; + +// GET /api/documents/[id]/signers — returns per-signer signing status +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session) return new Response('Unauthorized', { status: 401 }); + + const { id } = await params; + + const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) }); + if (!doc) return NextResponse.json([], { status: 404 }); + + const signers = (doc.signers ?? []) as DocumentSigner[]; + if (signers.length === 0) return NextResponse.json([]); + + const allTokens = await db.query.signingTokens.findMany({ + where: eq(signingTokens.documentId, id), + }); + + const signedEmails = new Set( + allTokens.filter(t => t.usedAt !== null && t.signerEmail).map(t => t.signerEmail!) + ); + + return NextResponse.json( + signers.map(s => ({ email: s.email, color: s.color, signed: signedEmails.has(s.email) })) + ); +} diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx index ae5c9a0..6e13852 100644 --- a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx @@ -12,6 +12,76 @@ const PreviewModal = dynamic(() => import('./PreviewModal').then(m => m.PreviewM /** Auto-assigned signer color palette (D-01). Cycles if more than 4 signers. */ const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b']; +/** Panel shown for Sent/Viewed documents — per-signer status + resend buttons */ +function SentPanel({ docId, signers, status }: { docId: string; signers: DocumentSigner[]; status: string }) { + const [resending, setResending] = useState(null); + const [resendResult, setResendResult] = useState>({}); + const [signedEmails, setSignedEmails] = useState>(new Set()); + + useEffect(() => { + // Fetch which signers have already signed + fetch(`/api/documents/${docId}/signers`) + .then(r => r.json()) + .then((data: { email: string; signed: boolean }[]) => { + setSignedEmails(new Set(data.filter(s => s.signed).map(s => s.email))); + }) + .catch(() => {}); + }, [docId]); + + async function handleResend(email: string) { + setResending(email); + try { + const res = await fetch(`/api/documents/${docId}/resend?signerEmail=${encodeURIComponent(email)}`, { method: 'POST' }); + setResendResult(prev => ({ ...prev, [email]: res.ok ? 'ok' : 'error' })); + } catch { + setResendResult(prev => ({ ...prev, [email]: 'error' })); + } finally { + setResending(null); + } + } + + return ( +
+

+ {status === 'Viewed' ? 'Document opened' : 'Document sent'} — waiting for signatures +

+ {signers.length === 0 ? ( +

No signers configured.

+ ) : ( +
+ {signers.map(signer => { + const signed = signedEmails.has(signer.email); + const result = resendResult[signer.email]; + return ( +
+ + {signer.email} + {signed ? ( + ✓ Signed + ) : ( +
+ Pending + + {result === 'ok' && Sent ✓} + {result === 'error' && Failed} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + interface PreparePanelProps { docId: string; defaultEmail: string; @@ -132,6 +202,12 @@ export function PreparePanel({ ); } + if (currentStatus === 'Sent' || currentStatus === 'Viewed') { + return ( + + ); + } + if (currentStatus !== 'Draft') { return (