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 { 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`);
}
};