feat: per-signer status panel with Resend button on sent documents, fix sign page field filter
This commit is contained in:
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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) }))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. */
|
/** Auto-assigned signer color palette (D-01). Cycles if more than 4 signers. */
|
||||||
const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
|
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 {
|
interface PreparePanelProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
defaultEmail: 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') {
|
if (currentStatus !== 'Draft') {
|
||||||
return (
|
return (
|
||||||
<div style={{ borderRadius: '0.5rem', border: '1px solid #E5E7EB', padding: '1rem', backgroundColor: '#F9FAFB', fontSize: '0.875rem', color: '#6B7280' }}>
|
<div style={{ borderRadius: '0.5rem', border: '1px solid #E5E7EB', padding: '1rem', backgroundColor: '#F9FAFB', fontSize: '0.875rem', color: '#6B7280' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user