From e681d53331e76b37184356fac26b0636d468c578 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 14 Oct 2024 18:58:52 +0530 Subject: [PATCH] if a workspace exceeds the number of clicks, we should stop sending click webhooks --- apps/web/app/api/track/click/route.ts | 1 + apps/web/app/api/webhooks/route.ts | 30 +++++---- apps/web/lib/middleware/link.ts | 8 +++ apps/web/lib/tinybird/record-click.ts | 94 +++++++++++++++++++-------- 4 files changed, 94 insertions(+), 39 deletions(-) diff --git a/apps/web/app/api/track/click/route.ts b/apps/web/app/api/track/click/route.ts index 2a07d67c86..7c2730c5a3 100644 --- a/apps/web/app/api/track/click/route.ts +++ b/apps/web/app/api/track/click/route.ts @@ -57,6 +57,7 @@ export const POST = async (req: Request) => { linkId: link.id, url: link.url, skipRatelimit: true, + workspaceId: workspace.id, }), ); } diff --git a/apps/web/app/api/webhooks/route.ts b/apps/web/app/api/webhooks/route.ts index 2cd5ce1e47..2e9dfd1858 100644 --- a/apps/web/app/api/webhooks/route.ts +++ b/apps/web/app/api/webhooks/route.ts @@ -2,7 +2,7 @@ import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { addWebhook } from "@/lib/webhook/api"; +import { addWebhook, updateWebhookStatusForWorkspace } from "@/lib/webhook/api"; import { transformWebhook } from "@/lib/webhook/transform"; import { createWebhookSchema } from "@/lib/zod/schemas/webhooks"; import { waitUntil } from "@vercel/functions"; @@ -97,20 +97,24 @@ export const POST = withWorkspace( }); waitUntil( - sendEmail({ - email: session.user.email, - subject: "New webhook added", - react: WebhookAdded({ + Promise.allSettled([ + updateWebhookStatusForWorkspace({ workspace }), + + sendEmail({ email: session.user.email, - workspace: { - name: workspace.name, - slug: workspace.slug, - }, - webhook: { - name, - }, + subject: "New webhook added", + react: WebhookAdded({ + email: session.user.email, + workspace: { + name: workspace.name, + slug: workspace.slug, + }, + webhook: { + name, + }, + }), }), - }), + ]), ); return NextResponse.json(transformWebhook(webhook), { status: 201 }); diff --git a/apps/web/lib/middleware/link.ts b/apps/web/lib/middleware/link.ts index 52e5882d61..fb281d1927 100644 --- a/apps/web/lib/middleware/link.ts +++ b/apps/web/lib/middleware/link.ts @@ -91,6 +91,7 @@ export default async function LinkMiddleware( expiredUrl, doIndex, webhookIds, + projectId: workspaceId, } = link; // by default, we only index default dub domain links (e.g. dub.sh) @@ -181,6 +182,7 @@ export default async function LinkMiddleware( clickId, url, webhookIds, + workspaceId, }), ); @@ -225,6 +227,7 @@ export default async function LinkMiddleware( clickId, url, webhookIds, + workspaceId, }), ); @@ -258,6 +261,7 @@ export default async function LinkMiddleware( clickId, url, webhookIds, + workspaceId, }), ); @@ -306,6 +310,7 @@ export default async function LinkMiddleware( clickId, url: ios, webhookIds, + workspaceId, }), ); @@ -335,6 +340,7 @@ export default async function LinkMiddleware( clickId, url: android, webhookIds, + workspaceId, }), ); @@ -364,6 +370,7 @@ export default async function LinkMiddleware( clickId, url: geo[country], webhookIds, + workspaceId, }), ); @@ -393,6 +400,7 @@ export default async function LinkMiddleware( clickId, url, webhookIds, + workspaceId, }), ); diff --git a/apps/web/lib/tinybird/record-click.ts b/apps/web/lib/tinybird/record-click.ts index 94d949d3be..51e3115bcd 100644 --- a/apps/web/lib/tinybird/record-click.ts +++ b/apps/web/lib/tinybird/record-click.ts @@ -15,6 +15,7 @@ import { getIdentityHash, } from "../middleware/utils"; import { conn } from "../planetscale"; +import { WorkspaceProps } from "../types"; import { redis } from "../upstash"; import { webhookCache } from "../webhook/cache"; import { sendWebhooks } from "../webhook/qstash"; @@ -30,6 +31,7 @@ export async function recordClick({ url, webhookIds, skipRatelimit, + workspaceId, }: { req: Request; linkId: string; @@ -37,6 +39,7 @@ export async function recordClick({ url?: string; webhookIds?: string[]; skipRatelimit?: boolean; + workspaceId: string | undefined; }) { const searchParams = new URL(req.url).searchParams; @@ -119,7 +122,9 @@ export async function recordClick({ referer_url: referer || "(direct)", }; - await Promise.allSettled([ + const hasWebhooks = webhookIds && webhookIds.length > 0; + + const [, , , , workspaceRows] = await Promise.all([ fetch( `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`, { @@ -146,37 +151,74 @@ export async function recordClick({ // and then we have a cron that will reset it at the start of new billing cycle url && conn.execute( - "UPDATE Project p JOIN Link l ON p.id = l.projectId SET p.usage = p.usage + 1 WHERE l.id = ?", + "UPDATE Project p JOIN Link l ON p.id = l.projectId SET p.usage = p.usage + 1 WHERE l.id = ?;", [linkId], ), + + // fetch the workspace usage for the workspace + workspaceId && hasWebhooks + ? conn.execute( + "SELECT usage, usageLimit FROM Project WHERE id = ? LIMIT 1", + [workspaceId], + ) + : null, ]); - // Send webhook events if link has webhooks enabled - if (webhookIds && webhookIds.length > 0) { - const webhooks = await webhookCache.mget(webhookIds); + const workspace = + workspaceRows && workspaceRows.rows.length > 0 + ? (workspaceRows.rows[0] as Pick) + : null; + + const hasExceededUsageLimit = + workspace && workspace.usage >= workspace.usageLimit; - const linkWebhooks = webhooks.filter( - (webhook) => - webhook.disabledAt === null && - webhook.triggers && - Array.isArray(webhook.triggers) && - webhook.triggers.includes("link.clicked"), + // Send webhook events if link has webhooks enabled and the workspace usage has not exceeded the limit + if (hasWebhooks && !hasExceededUsageLimit) { + await sendLinkClickWebhooks({ webhookIds, linkId, clickData }); + } +} + +async function sendLinkClickWebhooks({ + webhookIds, + linkId, + clickData, +}: { + webhookIds: string[]; + linkId: string; + clickData: any; +}) { + const webhooks = await webhookCache.mget(webhookIds); + + // Couldn't find webhooks in the cache + // TODO: Should we look them up in the database? + if (!webhooks || webhooks.length === 0) { + return; + } + + const activeLinkWebhooks = webhooks.filter((webhook) => { + return ( + !webhook.disabledAt && + webhook.triggers && + Array.isArray(webhook.triggers) && + webhook.triggers.includes("link.clicked") ); + }); - if (linkWebhooks.length > 0) { - const link = await conn - .execute("SELECT * FROM Link WHERE id = ?", [linkId]) - .then((res) => res.rows[0]); - - await sendWebhooks({ - trigger: "link.clicked", - webhooks: linkWebhooks, - // @ts-ignore – bot & qr should be boolean - data: transformClickEventData({ - ...clickData, - link: transformLink(link as LinkWithTags), - }), - }); - } + if (activeLinkWebhooks.length === 0) { + return; } + + const link = await conn + .execute("SELECT * FROM Link WHERE id = ?", [linkId]) + .then((res) => res.rows[0]); + + await sendWebhooks({ + trigger: "link.clicked", + webhooks: activeLinkWebhooks, + // @ts-ignore – bot & qr should be boolean + data: transformClickEventData({ + ...clickData, + link: transformLink(link as LinkWithTags), + }), + }); }