Skip to content

Commit

Permalink
Merge pull request #1340 from dubinc/link-webhook
Browse files Browse the repository at this point in the history
Add webhook to link builder and link APIs
  • Loading branch information
steven-tey authored Oct 14, 2024
2 parents c61b682 + 095e141 commit 4e4da94
Show file tree
Hide file tree
Showing 44 changed files with 633 additions and 212 deletions.
138 changes: 97 additions & 41 deletions apps/web/app/api/links/bulk/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { DubApiError, exceededLimitError } from "@/lib/api/errors";
import { bulkCreateLinks, combineTagIds, processLink } from "@/lib/api/links";
import {
bulkCreateLinks,
checkIfLinksHaveTags,
checkIfLinksHaveWebhooks,
combineTagIds,
processLink,
} from "@/lib/api/links";
import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links";
import { bulkUpdateLinks } from "@/lib/api/links/bulk-update-links";
import { throwIfLinksUsageExceeded } from "@/lib/api/links/usage-checks";
Expand Down Expand Up @@ -86,49 +92,99 @@ export const POST = withWorkspace(
code,
}));

// filter out tags that don't belong to the workspace
const workspaceTags = await prisma.tag.findMany({
where: {
projectId: workspace.id,
},
select: {
id: true,
name: true,
},
});
const workspaceTagIds = workspaceTags.map(({ id }) => id);
const workspaceTagNames = workspaceTags.map(({ name }) => name);
validLinks.forEach((link, index) => {
const combinedTagIds =
combineTagIds({
tagId: link.tagId,
tagIds: link.tagIds,
}) ?? [];
const invalidTagIds = combinedTagIds.filter(
(id) => !workspaceTagIds.includes(id),
);
if (invalidTagIds.length > 0) {
// remove link from validLinks and add error to errorLinks
validLinks = validLinks.filter((_, i) => i !== index);
errorLinks.push({
error: `Invalid tagIds detected: ${invalidTagIds.join(", ")}`,
code: "unprocessable_entity",
link,
});
}
if (checkIfLinksHaveTags(validLinks)) {
// filter out tags that don't belong to the workspace
const tagIds = validLinks
.map((link) =>
combineTagIds({ tagId: link.tagId, tagIds: link.tagIds }),
)
.flat()
.filter(Boolean) as string[];
const tagNames = validLinks
.map((link) => link.tagNames)
.flat()
.filter(Boolean) as string[];

const workspaceTags = await prisma.tag.findMany({
where: {
projectId: workspace.id,
...(tagIds.length > 0 ? { id: { in: tagIds } } : {}),
...(tagNames.length > 0 ? { name: { in: tagNames } } : {}),
},
select: {
id: true,
name: true,
},
});
const workspaceTagIds = workspaceTags.map(({ id }) => id);
const workspaceTagNames = workspaceTags.map(({ name }) => name);
validLinks.forEach((link, index) => {
const combinedTagIds =
combineTagIds({
tagId: link.tagId,
tagIds: link.tagIds,
}) ?? [];
const invalidTagIds = combinedTagIds.filter(
(id) => !workspaceTagIds.includes(id),
);
if (invalidTagIds.length > 0) {
// remove link from validLinks and add error to errorLinks
validLinks = validLinks.filter((_, i) => i !== index);
errorLinks.push({
error: `Invalid tagIds detected: ${invalidTagIds.join(", ")}`,
code: "unprocessable_entity",
link,
});
}

const invalidTagNames = link.tagNames?.filter(
(name) => !workspaceTagNames.includes(name),
);
if (invalidTagNames?.length) {
validLinks = validLinks.filter((_, i) => i !== index);
errorLinks.push({
error: `Invalid tagNames detected: ${invalidTagNames.join(", ")}`,
code: "unprocessable_entity",
link,
const invalidTagNames = link.tagNames?.filter(
(name) => !workspaceTagNames.includes(name),
);
if (invalidTagNames?.length) {
validLinks = validLinks.filter((_, i) => i !== index);
errorLinks.push({
error: `Invalid tagNames detected: ${invalidTagNames.join(", ")}`,
code: "unprocessable_entity",
link,
});
}
});
}

if (checkIfLinksHaveWebhooks(validLinks)) {
if (workspace.plan === "free" || workspace.plan === "pro") {
throw new DubApiError({
code: "forbidden",
message:
"You can only use webhooks on a Business plan and above. Upgrade to Business to use this feature.",
});
}
});

const webhookIds = validLinks
.map((link) => link.webhookIds)
.flat()
.filter((id): id is string => id !== null);

const webhooks = await prisma.webhook.findMany({
where: { projectId: workspace.id, id: { in: webhookIds } },
});

const workspaceWebhookIds = webhooks.map(({ id }) => id);

validLinks.forEach((link, index) => {
const invalidWebhookIds = link.webhookIds?.filter(
(id) => !workspaceWebhookIds.includes(id),
);
if (invalidWebhookIds && invalidWebhookIds.length > 0) {
validLinks = validLinks.filter((_, i) => i !== index);
errorLinks.push({
error: `Invalid webhookIds detected: ${invalidWebhookIds.join(", ")}`,
code: "unprocessable_entity",
link,
});
}
});
}

const validLinksResponse =
validLinks.length > 0 ? await bulkCreateLinks({ links: validLinks }) : [];
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/api/links/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const GET = withWorkspace(
showArchived,
withTags,
includeUser,
includeWebhooks,
linkIds,
} = getLinksQuerySchemaExtended.parse(searchParams);

Expand All @@ -52,6 +53,7 @@ export const GET = withWorkspace(
showArchived,
withTags,
includeUser,
includeWebhooks,
linkIds,
});

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/tags/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const GET = withWorkspace(
},
);

// POST /api/workspaces/[idOrSlug]/tags - create a tag for a workspace
// POST /api/tags - create a tag for a workspace
export const POST = withWorkspace(
async ({ req, workspace, headers }) => {
const tagsCount = await prisma.tag.count({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,25 @@
"use client";

import { clientAccessCheck } from "@/lib/api/tokens/permissions";
import useWebhooks from "@/lib/swr/use-webhooks";
import useWorkspace from "@/lib/swr/use-workspace";
import { WebhookProps } from "@/lib/types";
import EmptyState from "@/ui/shared/empty-state";
import WebhookCard from "@/ui/webhooks/webhook-card";
import WebhookPlaceholder from "@/ui/webhooks/webhook-placeholder";
import { Button, TooltipContent } from "@dub/ui";
import { InfoTooltip } from "@dub/ui/src/tooltip";
import { fetcher } from "@dub/utils";
import { Webhook } from "lucide-react";
import { redirect, useRouter } from "next/navigation";
import useSWR from "swr";

export default function WebhooksPageClient() {
const router = useRouter();
const {
id: workspaceId,
slug,
plan,
role,
conversionEnabled,
flags,
} = useWorkspace();
const { slug, plan, role, conversionEnabled, flags } = useWorkspace();

if (!flags?.webhooks) {
redirect(`/${slug}/settings`);
}

const { data: webhooks, isLoading } = useSWR<WebhookProps[]>(
`/api/webhooks?workspaceId=${workspaceId}`,
fetcher,
);
const { webhooks, isLoading } = useWebhooks();

const { error: permissionsError } = clientAccessCheck({
action: "webhooks.write",
Expand Down
29 changes: 23 additions & 6 deletions apps/web/lib/api/links/bulk-create-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
import { waitUntil } from "@vercel/functions";
import { propagateBulkLinkChanges } from "./propagate-bulk-link-changes";
import { updateLinksUsage } from "./update-links-usage";
import { combineTagIds, LinkWithTags, transformLink } from "./utils";
import {
checkIfLinksHaveTags,
checkIfLinksHaveWebhooks,
combineTagIds,
LinkWithTags,
transformLink,
} from "./utils";

export async function bulkCreateLinks({
links,
Expand All @@ -18,19 +24,18 @@ export async function bulkCreateLinks({
}) {
if (links.length === 0) return [];

const hasTags = links.some(
(link) => link.tagNames?.length || link.tagIds?.length || link.tagId,
);
const hasTags = checkIfLinksHaveTags(links);
const hasWebhooks = checkIfLinksHaveWebhooks(links);

let createdLinks: LinkWithTags[] = [];

if (hasTags) {
if (hasTags || hasWebhooks) {
// create links via Promise.all (because createMany doesn't return the created links)
// @see https://github.com/prisma/prisma/issues/8131#issuecomment-997667070
// there is createManyAndReturn but it's not available for MySQL :(
// @see https://www.prisma.io/docs/orm/reference/prisma-client-reference#createmanyandreturn
createdLinks = await Promise.all(
links.map(({ tagId, tagIds, tagNames, ...link }) => {
links.map(({ tagId, tagIds, tagNames, webhookIds, ...link }) => {
const shortLink = linkConstructor({
domain: link.domain,
key: link.key,
Expand Down Expand Up @@ -82,6 +87,17 @@ export async function bulkCreateLinks({
},
},
}),

...(webhookIds &&
webhookIds.length > 0 && {
webhooks: {
createMany: {
data: webhookIds.map((webhookId) => ({
webhookId,
})),
},
},
}),
},
include: {
tags: {
Expand All @@ -95,6 +111,7 @@ export async function bulkCreateLinks({
},
},
},
webhooks: hasWebhooks,
},
});
}),
Expand Down
16 changes: 15 additions & 1 deletion apps/web/lib/api/links/create-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function createLink(link: ProcessedLinkProps) {
const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } =
getParamsFromURL(url);

const { tagId, tagIds, tagNames, ...rest } = link;
const { tagId, tagIds, tagNames, webhookIds, ...rest } = link;

const response = await prisma.link.create({
data: {
Expand Down Expand Up @@ -69,6 +69,18 @@ export async function createLink(link: ProcessedLinkProps) {
},
},
}),

// Webhooks
...(webhookIds &&
webhookIds.length > 0 && {
webhooks: {
createMany: {
data: webhookIds.map((webhookId) => ({
webhookId,
})),
},
},
}),
},
include: {
tags: {
Expand All @@ -82,6 +94,7 @@ export async function createLink(link: ProcessedLinkProps) {
},
},
},
webhooks: webhookIds ? true : false,
},
});

Expand All @@ -93,6 +106,7 @@ export async function createLink(link: ProcessedLinkProps) {
redis.hset(link.domain.toLowerCase(), {
[link.key.toLowerCase()]: await formatRedisLink(response),
}),

// record link in Tinybird
recordLink({
link_id: response.id,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/lib/api/links/get-links-for-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export async function getLinksForWorkspace({
showArchived,
withTags,
includeUser,
includeWebhooks,
linkIds,
}: z.infer<typeof getLinksQuerySchemaExtended> & {
workspaceId: string;
Expand Down Expand Up @@ -64,7 +65,6 @@ export async function getLinksForWorkspace({
...(linkIds && { id: { in: linkIds } }),
},
include: {
user: includeUser,
tags: {
include: {
tag: {
Expand All @@ -76,6 +76,8 @@ export async function getLinksForWorkspace({
},
},
},
user: includeUser,
webhooks: includeWebhooks,
},
orderBy: {
[sort]: "desc",
Expand Down
Loading

0 comments on commit 4e4da94

Please sign in to comment.