diff --git a/apps/web/app/[domain]/placeholder.tsx b/apps/web/app/[domain]/placeholder.tsx index aabc4a820a..69678df760 100644 --- a/apps/web/app/[domain]/placeholder.tsx +++ b/apps/web/app/[domain]/placeholder.tsx @@ -74,7 +74,7 @@ export default function PlaceholderContent() { { - const { search, archived, page, pageSize } = - getDomainsQuerySchema.parse(searchParams); + const { search, archived, page, pageSize, includeLink } = + getDomainsQuerySchemaExtended.parse(searchParams); const domains = await prisma.domain.findMany({ where: { @@ -31,12 +30,43 @@ export const GET = withWorkspace( }, include: { registeredDomain: true, + ...(includeLink && { + links: { + where: { + key: "_root", + }, + include: { + tags: { + select: { + tag: { + select: { + id: true, + name: true, + color: true, + }, + }, + }, + }, + }, + }, + }), }, take: pageSize, skip: (page - 1) * pageSize, }); - return NextResponse.json(z.array(DomainSchema).parse(domains)); + const response = domains.map((domain) => ({ + ...DomainSchema.parse(domain), + ...(includeLink && + domain.links.length > 0 && { + link: transformLink({ + ...domain.links[0], + tags: domain.links[0]["tags"].map((tag) => tag), + }), + }), + })); + + return NextResponse.json(response); }, { requiredPermissions: ["domains.read"], @@ -47,7 +77,7 @@ export const GET = withWorkspace( export const POST = withWorkspace( async ({ req, workspace, session }) => { const body = await parseRequestBody(req); - const { slug, placeholder, expiredUrl } = + const { slug, placeholder, expiredUrl, notFoundUrl } = createDomainBodySchema.parse(body); const totalDomains = await prisma.domain.count({ @@ -102,9 +132,11 @@ export const POST = withWorkspace( ...(placeholder && { placeholder }), ...(workspace.plan !== "free" && { expiredUrl, + notFoundUrl, }), }, }), + createLink({ ...DEFAULT_LINK_PROPS, domain: slug, @@ -116,9 +148,15 @@ export const POST = withWorkspace( }), ]); - return NextResponse.json(DomainSchema.parse(domainRecord), { - status: 201, - }); + return NextResponse.json( + DomainSchema.parse({ + ...domainRecord, + registeredDomain: null, + }), + { + status: 201, + }, + ); }, { requiredPermissions: ["domains.write"], diff --git a/apps/web/app/api/links/bulk/route.ts b/apps/web/app/api/links/bulk/route.ts index fec5a263d8..4cbd33a53b 100644 --- a/apps/web/app/api/links/bulk/route.ts +++ b/apps/web/app/api/links/bulk/route.ts @@ -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"; @@ -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 }) : []; diff --git a/apps/web/app/api/links/route.ts b/apps/web/app/api/links/route.ts index 3f3df00f01..c8b46721ae 100644 --- a/apps/web/app/api/links/route.ts +++ b/apps/web/app/api/links/route.ts @@ -32,6 +32,7 @@ export const GET = withWorkspace( showArchived, withTags, includeUser, + includeWebhooks, linkIds, } = getLinksQuerySchemaExtended.parse(searchParams); @@ -52,6 +53,7 @@ export const GET = withWorkspace( showArchived, withTags, includeUser, + includeWebhooks, linkIds, }); diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts index 7a0a88ac2e..b2552dd9a0 100644 --- a/apps/web/app/api/tags/route.ts +++ b/apps/web/app/api/tags/route.ts @@ -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({ diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx index 75731f5915..e94b440fcb 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx @@ -43,7 +43,9 @@ export default function WorkspaceDomainsClient() { const [openPopover, setOpenPopover] = useState(false); const { searchParams, queryParams } = useRouterStuff(); - const { allWorkspaceDomains, loading } = useDomains({ includeParams: true }); + const { allWorkspaceDomains, loading } = useDomains({ + opts: { includeLink: "true" }, + }); const { data: domainsCount } = useDomainsCount(); const { pagination, setPagination } = usePagination(DOMAINS_MAX_PAGE_SIZE); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/webhooks/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/webhooks/page-client.tsx index 9d8a855717..d2c91ebaf4 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/webhooks/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/webhooks/page-client.tsx @@ -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( - `/api/webhooks?workspaceId=${workspaceId}`, - fetcher, - ); + const { webhooks, isLoading } = useWebhooks(); const { error: permissionsError } = clientAccessCheck({ action: "webhooks.write", diff --git a/apps/web/app/banned/page.tsx b/apps/web/app/banned/page.tsx index 3df03ad137..e42f2469f2 100644 --- a/apps/web/app/banned/page.tsx +++ b/apps/web/app/banned/page.tsx @@ -24,7 +24,7 @@ export default async function BannedPage() { This link has been banned for violating our terms of service.

Create Your Free Branded Link diff --git a/apps/web/app/expired/[domain]/page.tsx b/apps/web/app/expired/[domain]/page.tsx index 82449a1438..0527c9322f 100644 --- a/apps/web/app/expired/[domain]/page.tsx +++ b/apps/web/app/expired/[domain]/page.tsx @@ -1,7 +1,7 @@ -import { getLinkViaEdge } from "@/lib/planetscale"; +import { getDomainViaEdge } from "@/lib/planetscale/get-domain-via-edge"; import { Background, Footer, Nav, NavMobile } from "@dub/ui"; +import { CircleHalfDottedClock } from "@dub/ui/src/icons"; import { constructMetadata } from "@dub/utils"; -import { TimerOff } from "lucide-react"; import { redirect } from "next/navigation"; export const runtime = "edge"; @@ -13,12 +13,12 @@ export const metadata = constructMetadata({ noIndex: true, }); -export default async function ExpiredPage({ +export default async function ExpiredLinkPage({ params, }: { params: { domain: string }; }) { - const domain = await getLinkViaEdge(params.domain, "_root"); + const domain = await getDomainViaEdge(params.domain); if (domain?.expiredUrl) { redirect(domain.expiredUrl); @@ -29,8 +29,8 @@ export default async function ExpiredPage({