feat: update webhook handling and triggering

This commit is contained in:
Mythie
2024-02-27 15:16:14 +11:00
parent 488464e3e7
commit 7dd2bbd8ab
17 changed files with 263 additions and 90 deletions

View File

@ -0,0 +1,12 @@
import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler';
export const config = {
maxDuration: 300,
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
};
export default handlerTriggerWebhooks;

View File

@ -0,0 +1,12 @@
import { hashString } from '../auth/hash';
import { encryptSecondaryData } from './encrypt';
export const sign = (data: unknown) => {
const stringified = JSON.stringify(data);
const hashed = hashString(stringified);
const signature = encryptSecondaryData({ data: hashed });
return signature;
};

View File

@ -0,0 +1,12 @@
import { hashString } from '../auth/hash';
import { decryptSecondaryData } from './decrypt';
export const verify = (data: unknown, signature: string) => {
const stringified = JSON.stringify(data);
const hashed = hashString(stringified);
const decrypted = decryptSecondaryData(signature);
return decrypted === hashed;
};

View File

@ -7,7 +7,7 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../../universal/trigger-webhook';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
@ -134,7 +134,9 @@ export const completeDocumentWithToken = async ({
const updatedDocument = await getDocument({ token, documentId });
await triggerWebhook({
eventTrigger: WebhookTriggerEvents.DOCUMENT_SIGNED,
documentData: updatedDocument,
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: updatedDocument,
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
};

View File

@ -7,7 +7,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
import { prisma } from '@documenso/prisma';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../../universal/trigger-webhook';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
title: string;
@ -67,8 +67,10 @@ export const createDocument = async ({
});
await triggerWebhook({
eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED,
documentData: document,
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document;

View File

@ -13,10 +13,10 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { triggerWebhook } from '../../universal/trigger-webhook';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
@ -139,7 +139,9 @@ export const sealDocument = async ({
}
await triggerWebhook({
eventTrigger: WebhookTriggerEvents.DOCUMENT_COMPLETED,
documentData: document,
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: document,
userId: document.userId,
teamId: document.teamId ?? undefined,
});
};

View File

@ -17,7 +17,7 @@ import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { triggerWebhook } from '../../universal/trigger-webhook';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type SendDocumentOptions = {
documentId: number;
@ -189,8 +189,10 @@ export const sendDocument = async ({
});
await triggerWebhook({
eventTrigger: WebhookTriggerEvents.DOCUMENT_SENT,
documentData: updatedDocument,
event: WebhookTriggerEvents.DOCUMENT_SENT,
data: updatedDocument,
userId,
teamId,
});
return updatedDocument;

View File

@ -5,7 +5,7 @@ import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../../universal/trigger-webhook';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
export type ViewedDocumentOptions = {
@ -59,7 +59,9 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
const document = await getDocumentAndRecipientByToken({ token });
await triggerWebhook({
eventTrigger: WebhookTriggerEvents.DOCUMENT_OPENED,
documentData: document,
event: WebhookTriggerEvents.DOCUMENT_OPENED,
data: document,
userId: document.userId,
teamId: document.teamId ?? undefined,
});
};

View File

@ -2,16 +2,35 @@ import { prisma } from '@documenso/prisma';
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
export type GetAllWebhooksByEventTriggerOptions = {
eventTrigger: WebhookTriggerEvents;
event: WebhookTriggerEvents;
userId: number;
teamId?: number;
};
export const getAllWebhooksByEventTrigger = async ({
eventTrigger,
event,
userId,
teamId,
}: GetAllWebhooksByEventTriggerOptions) => {
return prisma.webhook.findMany({
where: {
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
eventTriggers: {
has: eventTrigger,
has: event,
},
enabled: true,
},

View File

@ -0,0 +1,58 @@
import { prisma } from '@documenso/prisma';
import {
Prisma,
type Webhook,
WebhookCallStatus,
type WebhookTriggerEvents,
} from '@documenso/prisma/client';
export type ExecuteWebhookOptions = {
event: WebhookTriggerEvents;
webhook: Webhook;
data: unknown;
};
export const executeWebhook = async ({ event, webhook, data }: ExecuteWebhookOptions) => {
const { webhookUrl: url, secret } = webhook;
console.log('Executing webhook', { event, url });
const payload = {
event,
payload: data,
createdAt: new Date().toISOString(),
webhookEndpoint: url,
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
await prisma.webhookCall.create({
data: {
url,
event,
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
requestBody: payload as Prisma.InputJsonValue,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
webhookId: webhook.id,
},
});
};

View File

@ -0,0 +1,58 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { verify } from '../../crypto/verify';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
import { executeWebhook } from './execute-webhook';
import { ZTriggerWebhookBodySchema } from './schema';
export type HandlerTriggerWebhooksResponse =
| {
success: true;
message: string;
}
| {
success: false;
error: string;
};
export const handlerTriggerWebhooks = async (
req: NextApiRequest,
res: NextApiResponse<HandlerTriggerWebhooksResponse>,
) => {
const signature = req.headers['x-webhook-signature'];
if (typeof signature !== 'string') {
console.log('Missing signature');
return res.status(400).json({ success: false, error: 'Missing signature' });
}
const valid = verify(req.body, signature);
if (!valid) {
console.log('Invalid signature');
return res.status(400).json({ success: false, error: 'Invalid signature' });
}
const result = ZTriggerWebhookBodySchema.safeParse(req.body);
if (!result.success) {
console.log('Invalid request body');
return res.status(400).json({ success: false, error: 'Invalid request body' });
}
const { event, data, userId, teamId } = result.data;
const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
await Promise.allSettled(
allWebhooks.map(async (webhook) =>
executeWebhook({
event,
webhook,
data,
}),
),
);
return res.status(200).json({ success: true, message: 'Webhooks executed successfully' });
};

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
export const ZTriggerWebhookBodySchema = z.object({
event: z.nativeEnum(WebhookTriggerEvents),
data: z.unknown(),
userId: z.number(),
teamId: z.number().optional(),
});
export type TTriggerWebhookBodySchema = z.infer<typeof ZTriggerWebhookBodySchema>;

View File

@ -0,0 +1,40 @@
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
export type TriggerWebhookOptions = {
event: WebhookTriggerEvents;
data: Record<string, unknown>;
userId: number;
teamId?: number;
};
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
try {
const body = {
event,
data,
userId,
teamId,
};
const signature = sign(body);
await Promise.race([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-webhook-signature': signature,
},
body: JSON.stringify(body),
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 500);
}),
]).catch(() => null);
} catch (err) {
throw new Error(`Failed to trigger webhook`);
}
};

View File

@ -1,39 +0,0 @@
import type { Document, Webhook } from '@documenso/prisma/client';
export type PostWebhookPayloadOptions = {
webhookData: Pick<Webhook, 'webhookUrl' | 'secret' | 'eventTriggers'>;
documentData: Document;
};
export const postWebhookPayload = async ({
webhookData,
documentData,
}: PostWebhookPayloadOptions) => {
const { webhookUrl, secret } = webhookData;
const payload = {
event: webhookData.eventTriggers.toString(),
createdAt: new Date().toISOString(),
webhookEndpoint: webhookUrl,
payload: documentData,
};
const response = await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
if (!response.ok) {
throw new Error(`Webhook failed with the status code ${response.status}`);
}
return {
status: response.status,
statusText: response.statusText,
message: 'Webhook sent successfully',
};
};

View File

@ -1,30 +0,0 @@
import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client';
import { getAllWebhooksByEventTrigger } from '../server-only/webhooks/get-all-webhooks-by-event-trigger';
import { postWebhookPayload } from './post-webhook-payload';
export type TriggerWebhookOptions = {
eventTrigger: WebhookTriggerEvents;
documentData: Document;
};
export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => {
try {
const allWebhooks = await getAllWebhooksByEventTrigger({ eventTrigger });
const webhookPromises = allWebhooks.map((webhook) => {
const { webhookUrl, secret } = webhook;
postWebhookPayload({
webhookData: { webhookUrl, secret, eventTriggers: [eventTrigger] },
documentData,
}).catch((_err) => {
throw new Error(`Failed to send webhook to ${webhookUrl}`);
});
});
return Promise.all(webhookPromises);
} catch (err) {
throw new Error(`Failed to trigger webhook`);
}
};

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `event` to the `WebhookCall` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "WebhookCall" ADD COLUMN "event" "WebhookTriggerEvents" NOT NULL;

View File

@ -126,16 +126,17 @@ enum WebhookCallStatus {
}
model WebhookCall {
id String @id @default(cuid())
id String @id @default(cuid())
status WebhookCallStatus
url String
event WebhookTriggerEvents
requestBody Json
responseCode Int
responseHeaders Json?
responseBody Json?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
webhookId String
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
}
enum ApiTokenAlgorithm {