feat(webhook): add link.created, document.created triggers

This commit is contained in:
Marc Seitz
2025-03-26 11:29:39 +04:00
parent bd4bd94950
commit 8f60a7da08
14 changed files with 496 additions and 57 deletions

View File

@@ -1,44 +1,59 @@
import { receiver } from "@/lib/cron";
import { z } from "zod";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import prisma from "@/lib/prisma";
import { recordWebhookEvent } from "@/lib/tinybird/publish";
import { getSearchParams } from "@/lib/utils/get-search-params";
import { WEBHOOK_TRIGGERS } from "@/lib/webhook/constants";
import {
webhookCallbackSchema,
webhookPayloadSchema,
} from "@/lib/zod/schemas/webhooks";
const searchParamsSchema = z.object({
webhookId: z.string(),
eventId: z.string(),
event: z.enum(WEBHOOK_TRIGGERS),
});
// POST /api/webhooks/callback  listen to webhooks status from QStash
export const POST = async (req: Request) => {
const rawBody = await req.json();
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(rawBody),
const rawBody = await req.text();
await verifyQstashSignature({
req,
rawBody,
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
const { url, status, body, sourceBody, sourceMessageId } =
webhookCallbackSchema.parse(rawBody);
webhookCallbackSchema.parse(JSON.parse(rawBody));
const { webhookId, eventId, event } = searchParamsSchema.parse(
getSearchParams(req.url),
);
const webhook = await prisma.webhook.findUnique({
where: { id: webhookId },
});
if (!webhook) {
console.error("Webhook not found", { webhookId });
return new Response("Webhook not found");
}
const request = Buffer.from(sourceBody, "base64").toString("utf-8");
const response = Buffer.from(body, "base64").toString("utf-8");
const { id: eventId, event } = webhookPayloadSchema.parse(
JSON.parse(request),
);
const webhookId = new URL(req.url).searchParams.get("webhookId");
const isFailed = status >= 400 || status === -1;
await recordWebhookEvent({
url,
event,
event_id: eventId,
http_status: status,
http_status: status === -1 ? 503 : status,
webhook_id: webhookId || "",
request_body: request,
response_body: response,
message_id: sourceMessageId,
});
return new Response("OK");
return new Response(`Webhook ${webhookId} processed`);
};

View File

@@ -129,13 +129,18 @@ export async function sendLinkViewWebhook({
documentId
? prisma.document.findUnique({
where: { id: documentId, teamId },
select: { id: true, name: true, contentType: true },
select: {
id: true,
name: true,
contentType: true,
createdAt: true,
},
})
: null,
dataroomId
? prisma.dataroom.findUnique({
where: { id: dataroomId, teamId },
select: { id: true, name: true },
select: { id: true, name: true, createdAt: true },
})
: null,
]);
@@ -150,6 +155,7 @@ export async function sendLinkViewWebhook({
name: document.name,
contentType: document.contentType,
teamId: teamId,
createdAt: document.createdAt.toISOString(),
},
}),
...(dataroom && {
@@ -157,6 +163,7 @@ export async function sendLinkViewWebhook({
id: dataroom.id,
name: dataroom.name,
teamId: teamId,
createdAt: dataroom.createdAt.toISOString(),
},
}),
};

39
lib/cron/verify-qstash.ts Normal file
View File

@@ -0,0 +1,39 @@
import { receiver } from ".";
import { log } from "../utils";
export const verifyQstashSignature = async ({
req,
rawBody,
}: {
req: Request;
rawBody: string; // Make sure to pass the raw body not the parsed JSON
}) => {
// skip verification in local development
if (process.env.VERCEL !== "1") {
return;
}
const signature = req.headers.get("Upstash-Signature");
if (!signature) {
throw new Error("Upstash-Signature header not found.");
}
const isValid = await receiver.verify({
signature,
body: rawBody,
});
if (!isValid) {
const url = req.url;
const messageId = req.headers.get("Upstash-Message-Id");
log({
message: `Invalid QStash request signature: *${url}* - *${messageId}*`,
type: "error",
mention: true,
});
throw new Error("Invalid QStash request signature.");
}
};

View File

@@ -0,0 +1,10 @@
export const getSearchParams = (url: string) => {
// Create a params object
let params = {} as Record<string, string>;
new URL(url).searchParams.forEach(function (val, key) {
params[key] = val;
});
return params;
};

View File

@@ -1,12 +1,10 @@
import { Webhook } from "@prisma/client";
import { z } from "zod";
import { qstash } from "@/lib/cron";
import { webhookPayloadSchema } from "@/lib/zod/schemas/webhooks";
import { createWebhookSignature } from "./signature";
import { prepareWebhookPayload } from "./transform";
import { EventDataProps, WebhookTrigger } from "./types";
import { EventDataProps, WebhookPayload, WebhookTrigger } from "./types";
// Send webhooks to multiple webhooks
export const sendWebhooks = async ({
@@ -37,9 +35,16 @@ const publishWebhookEventToQStash = async ({
payload,
}: {
webhook: Pick<Webhook, "pId" | "url" | "secret">;
payload: z.infer<typeof webhookPayloadSchema>;
payload: WebhookPayload;
}) => {
const callbackUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/api/webhooks/callback?webhookId=${webhook.pId}`;
// TODO: add proper domain like app.papermark.dev in dev
const callbackUrl = new URL(
`https://app.papermark.dev/api/webhooks/callback`,
);
callbackUrl.searchParams.append("webhookId", webhook.pId);
callbackUrl.searchParams.append("eventId", payload.id);
callbackUrl.searchParams.append("event", payload.event);
const signature = await createWebhookSignature(webhook.secret, payload);
const response = await qstash.publishJSON({
@@ -49,8 +54,8 @@ const publishWebhookEventToQStash = async ({
"X-Papermark-Signature": signature,
"Upstash-Hide-Headers": "true",
},
callback: callbackUrl,
failureCallback: callbackUrl,
callback: callbackUrl.href,
failureCallback: callbackUrl.href,
});
if (!response.messageId) {

View File

@@ -0,0 +1,86 @@
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
import { sendWebhooks } from "@/lib/webhook/send-webhooks";
export async function sendDocumentCreatedWebhook({
teamId,
data,
}: {
teamId: string;
data: any;
}) {
try {
const { document_id: documentId } = data;
if (!documentId || !teamId) {
throw new Error("Missing required parameters");
}
const features = await getFeatureFlags({ teamId });
if (!features.webhooks) {
// webhooks are not enabled for this team
return;
}
// Get webhooks for team
const webhooks = await prisma.webhook.findMany({
where: {
teamId,
triggers: {
array_contains: ["document.created"],
},
},
select: {
pId: true,
url: true,
secret: true,
},
});
if (!webhooks || (webhooks && webhooks.length === 0)) {
// No webhooks for team, so we don't need to send webhooks
return;
}
// Get document information
const document = await prisma.document.findUnique({
where: { id: documentId, teamId },
});
if (!document) {
throw new Error("Document not found");
}
// Prepare document data for webhook
const documentData = {
id: document.id,
name: document.name,
contentType: document.contentType,
teamId: document.teamId,
createdAt: document.createdAt.toISOString(),
};
// Prepare webhook payload
const webhookData = {
document: documentData,
};
// Send webhooks
if (webhooks.length > 0) {
await sendWebhooks({
webhooks,
trigger: "document.created",
data: webhookData,
});
}
return;
} catch (error) {
log({
message: `Error sending webhooks for document created: ${error}`,
type: "error",
mention: true,
});
return;
}
}

View File

@@ -0,0 +1,156 @@
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
import { sendWebhooks } from "@/lib/webhook/send-webhooks";
export async function sendLinkCreatedWebhook({
teamId,
data,
}: {
teamId: string;
data: any;
}) {
try {
const {
link_id: linkId,
document_id: documentId,
dataroom_id: dataroomId,
} = data;
if (!linkId || !teamId) {
throw new Error("Missing required parameters");
}
const features = await getFeatureFlags({ teamId });
if (!features.webhooks) {
// webhooks are not enabled for this team
return;
}
// Get webhooks for team
const webhooks = await prisma.webhook.findMany({
where: {
teamId,
triggers: {
array_contains: ["link.created"],
},
},
select: {
pId: true,
url: true,
secret: true,
},
});
if (!webhooks || (webhooks && webhooks.length === 0)) {
// No webhooks for team, so we don't need to send webhooks
return;
}
// Get link information
const link = await prisma.link.findUnique({
where: { id: linkId, teamId },
});
if (!link) {
throw new Error("Link not found");
}
// Prepare link data for webhook
const linkData = {
id: link.id,
url: link.domainId
? `https://${link.domainSlug}/${link.slug}`
: `https://www.papermark.com/view/${link.id}`,
domain:
link.domainId && link.domainSlug ? link.domainSlug : "papermark.com",
key: link.domainId && link.slug ? link.slug : `view/${link.id}`,
name: link.name,
expiresAt: link.expiresAt?.toISOString() || null,
hasPassword: !!link.password,
allowList: link.allowList,
denyList: link.denyList,
enabledEmailProtection: link.emailProtected,
enabledEmailVerification: link.emailAuthenticated,
allowDownload: link.allowDownload ?? false,
isArchived: link.isArchived,
enabledNotification: link.enableNotification ?? false,
enabledFeedback: link.enableFeedback ?? false,
enabledQuestion: link.enableQuestion ?? false,
enabledScreenshotProtection: link.enableScreenshotProtection ?? false,
enabledAgreement: link.enableAgreement ?? false,
enabledWatermark: link.enableWatermark ?? false,
metaTitle: link.metaTitle,
metaDescription: link.metaDescription,
metaImage: link.metaImage,
metaFavicon: link.metaFavicon,
documentId: link.documentId,
dataroomId: link.dataroomId,
groupId: link.groupId,
linkType: link.linkType,
teamId: teamId,
createdAt: link.createdAt.toISOString(),
updatedAt: link.updatedAt.toISOString(),
};
// Get document and dataroom information for webhook in parallel
const [document, dataroom] = await Promise.all([
documentId
? prisma.document.findUnique({
where: { id: documentId, teamId },
select: {
id: true,
name: true,
contentType: true,
createdAt: true,
},
})
: null,
dataroomId
? prisma.dataroom.findUnique({
where: { id: dataroomId, teamId },
select: { id: true, name: true, createdAt: true },
})
: null,
]);
// Prepare webhook payload
const webhookData = {
link: linkData,
...(document && {
document: {
id: document.id,
name: document.name,
contentType: document.contentType,
teamId: teamId,
createdAt: document.createdAt.toISOString(),
},
}),
...(dataroom && {
dataroom: {
id: dataroom.id,
name: dataroom.name,
teamId: teamId,
createdAt: dataroom.createdAt.toISOString(),
},
}),
};
// Send webhooks
if (webhooks.length > 0) {
await sendWebhooks({
webhooks,
trigger: "link.created",
data: webhookData,
});
}
return;
} catch (error) {
log({
message: `Error sending webhooks for link created: ${error}`,
type: "error",
mention: true,
});
return;
}
}

View File

@@ -1,11 +1,20 @@
import { z } from "zod";
import { webhookPayloadSchema } from "../zod/schemas/webhooks";
import {
dataroomCreatedWebhookSchema,
documentCreatedWebhookSchema,
linkCreatedWebhookSchema,
webhookPayloadSchema,
} from "../zod/schemas/webhooks";
import { WEBHOOK_TRIGGER_DESCRIPTIONS } from "./constants";
export type WebhookTrigger = keyof typeof WEBHOOK_TRIGGER_DESCRIPTIONS;
export type WebhookPayload = z.infer<typeof webhookPayloadSchema>;
export type WebhookPayload =
| z.infer<typeof webhookPayloadSchema>
| z.infer<typeof linkCreatedWebhookSchema>
| z.infer<typeof documentCreatedWebhookSchema>
| z.infer<typeof dataroomCreatedWebhookSchema>;
// TODO: only show the link.viewed data props for now
// TODO: only show the link.viewed, link.created, document.created data props for now
export type EventDataProps = WebhookPayload["data"];

View File

@@ -74,30 +74,85 @@ const linkEventSchema = z.object({
updatedAt: z.string().datetime(),
});
// Complete webhook payload schema
export const webhookPayloadSchema = baseEventSchema.extend({
// Document Event schema
const documentEventSchema = z.object({
id: z.string(),
name: z.string(),
contentType: z.string().nullable(),
teamId: z.string(),
createdAt: z.string().datetime(),
});
// Dataroom Event schema
const dataroomEventSchema = z.object({
id: z.string(),
name: z.string(),
teamId: z.string(),
createdAt: z.string().datetime(),
});
// link.created
export const linkCreatedWebhookSchema = z.object({
id: z.string().startsWith("evt_"),
event: z.literal("link.created"),
createdAt: z.string().datetime(),
data: z.object({
view: viewEventSchema,
link: linkEventSchema,
document: z
.object({
id: z.string(),
name: z.string(),
contentType: z.string().nullable(),
teamId: z.string(),
})
.optional(),
dataroom: z
.object({
id: z.string(),
name: z.string(),
teamId: z.string(),
})
.optional(),
document: documentEventSchema.optional(),
dataroom: dataroomEventSchema.optional(),
}),
});
// document.created
export const documentCreatedWebhookSchema = z.object({
id: z.string().startsWith("evt_"),
event: z.literal("document.created"),
createdAt: z.string().datetime(),
data: z.object({
document: documentEventSchema,
}),
});
// dataroom.created
export const dataroomCreatedWebhookSchema = z.object({
id: z.string().startsWith("evt_"),
event: z.literal("dataroom.created"),
createdAt: z.string().datetime(),
data: z.object({
dataroom: dataroomEventSchema,
}),
});
// link.viewed
export const linkViewedWebhookSchema = z.object({
id: z.string().startsWith("evt_"),
event: z.literal("link.viewed"),
createdAt: z.string().datetime(),
data: z.object({
view: viewEventSchema,
link: linkEventSchema,
document: documentEventSchema.optional(),
dataroom: dataroomEventSchema.optional(),
}),
});
export const webhookPayloadSchema = z.discriminatedUnion("event", [
linkCreatedWebhookSchema,
documentCreatedWebhookSchema,
dataroomCreatedWebhookSchema,
linkViewedWebhookSchema,
]);
export type WebhookPayload = z.infer<typeof webhookPayloadSchema>;
export type LinkCreatedWebhookPayload = z.infer<
typeof linkCreatedWebhookSchema
>;
export type DocumentCreatedWebhookPayload = z.infer<
typeof documentCreatedWebhookSchema
>;
export type DataroomCreatedWebhookPayload = z.infer<
typeof dataroomCreatedWebhookSchema
>;
// Schema of response sent to the webhook callback URL by QStash
export const webhookCallbackSchema = z.object({

View File

@@ -1,14 +1,21 @@
import { NextApiRequest, NextApiResponse } from "next";
import { Prisma } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import { errorhandler } from "@/lib/errorHandler";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { sendLinkCreatedWebhook } from "@/lib/webhook/triggers/link-created";
import { authOptions } from "../../auth/[...nextauth]";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
@@ -69,6 +76,17 @@ export default async function handle(
views: [],
};
waitUntil(
sendLinkCreatedWebhook({
teamId,
data: {
link_id: newLink.id,
document_id: newLink.documentId,
dataroom_id: newLink.dataroomId,
},
}),
);
return res.status(201).json(linkWithView);
} catch (error) {
errorhandler(error, res);

View File

@@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
import { LinkAudienceType } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import { errorhandler } from "@/lib/errorHandler";
@@ -10,9 +11,15 @@ import {
decryptEncrpytedPassword,
generateEncrpytedPassword,
} from "@/lib/utils";
import { sendLinkCreatedWebhook } from "@/lib/webhook/triggers/link-created";
import { authOptions } from "../auth/[...nextauth]";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
@@ -194,6 +201,17 @@ export default async function handler(
return res.status(404).json({ error: "Link not found" });
}
waitUntil(
sendLinkCreatedWebhook({
teamId,
data: {
link_id: link.id,
document_id: link.documentId,
dataroom_id: link.dataroomId,
},
}),
);
// Decrypt the password for the new link
if (linkWithView.password !== null) {
linkWithView.password = decryptEncrpytedPassword(linkWithView.password);

View File

@@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from "next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { DocumentStorageType, Prisma } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import { parsePageId } from "notion-utils";
@@ -19,6 +20,12 @@ import { convertPdfToImageRoute } from "@/lib/trigger/pdf-to-image-route";
import { CustomUser } from "@/lib/types";
import { getExtension, log } from "@/lib/utils";
import { conversionQueue } from "@/lib/utils/trigger-utils";
import { sendDocumentCreatedWebhook } from "@/lib/webhook/triggers/document-created";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
@@ -348,6 +355,15 @@ export default async function handle(
);
}
waitUntil(
sendDocumentCreatedWebhook({
teamId,
data: {
document_id: document.id,
},
}),
);
return res.status(201).json(document);
} catch (error) {
log({

View File

@@ -9,6 +9,8 @@ import { ArrowLeft, Check, Copy, WebhookIcon } from "lucide-react";
import { toast } from "sonner";
import useSWR from "swr";
import { cn, fetcher } from "@/lib/utils";
import AppLayout from "@/components/layouts/app";
import { SettingsHeader } from "@/components/settings/settings-header";
import { Button } from "@/components/ui/button";
@@ -19,8 +21,6 @@ import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { WebhookEventList } from "@/components/webhooks/webhook-events";
import { cn, fetcher } from "@/lib/utils";
import { documentEvents, linkEvents, teamEvents } from "../new";
type WebhookFormData = {
@@ -200,7 +200,10 @@ export default function WebhookDetail() {
),
}));
}}
disabled={true}
disabled={
!isEditing ||
event.value !== "document.created"
}
/>
<Label htmlFor={event.id}>{event.label}</Label>
</div>
@@ -231,7 +234,9 @@ export default function WebhookDetail() {
),
}));
}}
disabled={true}
disabled={
!isEditing || event.value !== "link.created"
}
/>
<Label htmlFor={event.id}>{event.label}</Label>
</div>

View File

@@ -9,6 +9,9 @@ import { ArrowLeft, Check } from "lucide-react";
import { toast } from "sonner";
import useSWR from "swr";
import { newId } from "@/lib/id-helper";
import { fetcher } from "@/lib/utils";
import AppLayout from "@/components/layouts/app";
import { SettingsHeader } from "@/components/settings/settings-header";
import Copy from "@/components/shared/icons/copy";
@@ -18,9 +21,6 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { newId } from "@/lib/id-helper";
import { fetcher } from "@/lib/utils";
interface WebhookEvent {
id: string;
label: string;
@@ -185,7 +185,7 @@ export default function NewWebhook() {
checked={formData.triggers.includes(
event.value,
)}
disabled={true}
disabled={event.value !== "document.created"}
onCheckedChange={(checked) => {
setFormData((prev) => ({
...prev,
@@ -216,7 +216,7 @@ export default function NewWebhook() {
checked={formData.triggers.includes(
event.value,
)}
disabled={true}
disabled={event.value !== "link.created"}
onCheckedChange={(checked) => {
setFormData((prev) => ({
...prev,