94 lines
3.0 KiB
TypeScript
94 lines
3.0 KiB
TypeScript
|
|
import { NextRequest, NextResponse } from 'next/server';
|
||
|
|
import { createHash } from 'crypto';
|
||
|
|
|
||
|
|
import { db } from '@/lib/db';
|
||
|
|
import { verifyDocumensoSignature } from '@/lib/services/documenso-webhook';
|
||
|
|
import {
|
||
|
|
handleRecipientSigned,
|
||
|
|
handleDocumentCompleted,
|
||
|
|
handleDocumentExpired,
|
||
|
|
} from '@/lib/services/documents.service';
|
||
|
|
import { env } from '@/lib/env';
|
||
|
|
import { logger } from '@/lib/logger';
|
||
|
|
|
||
|
|
// BR-024: Dedup via signatureHash unique index on documentEvents
|
||
|
|
// Always return 200 from webhook (webhook best practice)
|
||
|
|
|
||
|
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||
|
|
let payload: string;
|
||
|
|
|
||
|
|
try {
|
||
|
|
payload = await req.text();
|
||
|
|
} catch {
|
||
|
|
return NextResponse.json({ ok: false }, { status: 200 });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify HMAC signature
|
||
|
|
const signature = req.headers.get('x-documenso-signature') ?? '';
|
||
|
|
|
||
|
|
if (!verifyDocumensoSignature(payload, signature, env.DOCUMENSO_WEBHOOK_SECRET)) {
|
||
|
|
logger.warn({ signature }, 'Invalid Documenso webhook signature');
|
||
|
|
return NextResponse.json({ ok: false, error: 'Invalid signature' }, { status: 200 });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Compute deduplication hash
|
||
|
|
const signatureHash = createHash('sha256').update(payload).digest('hex');
|
||
|
|
|
||
|
|
let parsed: { type: string; payload: Record<string, unknown> };
|
||
|
|
|
||
|
|
try {
|
||
|
|
parsed = JSON.parse(payload) as typeof parsed;
|
||
|
|
} catch {
|
||
|
|
logger.warn('Failed to parse Documenso webhook payload');
|
||
|
|
return NextResponse.json({ ok: false }, { status: 200 });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Dedup: try to insert a sentinel documentEvent with signatureHash
|
||
|
|
// We need a documentId — if dedup fails at this stage we can't easily check.
|
||
|
|
// Instead, store the hash lookup on the first real documentEvent insert in handlers.
|
||
|
|
// Here we just check if this hash was already seen in any event.
|
||
|
|
try {
|
||
|
|
const existing = await db.query.documentEvents.findFirst({
|
||
|
|
where: (de, { eq }) => eq(de.signatureHash, signatureHash),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (existing) {
|
||
|
|
logger.info({ signatureHash }, 'Duplicate Documenso webhook — skipping');
|
||
|
|
return NextResponse.json({ ok: true }, { status: 200 });
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
logger.error({ err }, 'Failed to check duplicate webhook');
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
switch (parsed.type) {
|
||
|
|
case 'RECIPIENT_SIGNED':
|
||
|
|
await handleRecipientSigned({
|
||
|
|
documentId: parsed.payload.documentId as string,
|
||
|
|
recipientEmail: parsed.payload.recipientEmail as string,
|
||
|
|
signatureHash,
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
|
||
|
|
case 'DOCUMENT_COMPLETED':
|
||
|
|
await handleDocumentCompleted({
|
||
|
|
documentId: parsed.payload.documentId as string,
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
|
||
|
|
case 'DOCUMENT_EXPIRED':
|
||
|
|
await handleDocumentExpired({
|
||
|
|
documentId: parsed.payload.documentId as string,
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
|
||
|
|
default:
|
||
|
|
logger.info({ type: parsed.type }, 'Unhandled Documenso webhook event type');
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
logger.error({ err, type: parsed.type }, 'Error processing Documenso webhook');
|
||
|
|
}
|
||
|
|
|
||
|
|
return NextResponse.json({ ok: true }, { status: 200 });
|
||
|
|
}
|