feat: per-signer status panel with Resend button on sent documents, fix sign page field filter

This commit is contained in:
Chandler Copeland
2026-04-03 18:21:07 -06:00
parent bc0495dea9
commit e2bda51d91
3 changed files with 164 additions and 0 deletions

View File

@@ -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() });
}

View File

@@ -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) }))
);
}

View File

@@ -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<string | null>(null);
const [resendResult, setResendResult] = useState<Record<string, 'ok' | 'error'>>({});
const [signedEmails, setSignedEmails] = useState<Set<string>>(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 (
<div style={{ borderRadius: '0.5rem', border: '1px solid #E5E7EB', padding: '1rem', backgroundColor: '#F9FAFB' }}>
<p style={{ fontSize: '0.875rem', fontWeight: 600, color: '#1B2B4B', marginBottom: '0.75rem' }}>
{status === 'Viewed' ? 'Document opened' : 'Document sent'} waiting for signatures
</p>
{signers.length === 0 ? (
<p style={{ fontSize: '0.875rem', color: '#6B7280' }}>No signers configured.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{signers.map(signer => {
const signed = signedEmails.has(signer.email);
const result = resendResult[signer.email];
return (
<div key={signer.email} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', backgroundColor: 'white', border: '1px solid #E5E7EB', borderRadius: '0.375rem' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: signer.color, flexShrink: 0, display: 'inline-block' }} />
<span style={{ flex: 1, fontSize: '0.875rem', color: '#374151', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{signer.email}</span>
{signed ? (
<span style={{ fontSize: '0.75rem', color: '#059669', fontWeight: 600, flexShrink: 0 }}> Signed</span>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', flexShrink: 0 }}>
<span style={{ fontSize: '0.75rem', color: '#9CA3AF' }}>Pending</span>
<button
type="button"
onClick={() => handleResend(signer.email)}
disabled={resending === signer.email}
style={{ fontSize: '0.75rem', padding: '0.25rem 0.5rem', backgroundColor: '#1B2B4B', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer', opacity: resending === signer.email ? 0.5 : 1 }}
>
{resending === signer.email ? '…' : 'Resend'}
</button>
{result === 'ok' && <span style={{ fontSize: '0.75rem', color: '#059669' }}>Sent </span>}
{result === 'error' && <span style={{ fontSize: '0.75rem', color: '#DC2626' }}>Failed</span>}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
interface PreparePanelProps {
docId: string;
defaultEmail: string;
@@ -132,6 +202,12 @@ export function PreparePanel({
);
}
if (currentStatus === 'Sent' || currentStatus === 'Viewed') {
return (
<SentPanel docId={docId} signers={signers} status={currentStatus} />
);
}
if (currentStatus !== 'Draft') {
return (
<div style={{ borderRadius: '0.5rem', border: '1px solid #E5E7EB', padding: '1rem', backgroundColor: '#F9FAFB', fontSize: '0.875rem', color: '#6B7280' }}>