From 26fe22398967795d69ce890374e91ec93ccb1e57 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 22 Oct 2025 12:56:32 -0700 Subject: [PATCH] Remove transfer feature --- .../(ee)/api/cron/domains/transfer/route.ts | 116 ------------ .../(ee)/api/cron/domains/transfer/utils.ts | 59 ------ .../api/domains/[domain]/transfer/route.ts | 165 ----------------- apps/web/app/api/links/[linkId]/route.ts | 3 +- .../app/api/links/[linkId]/transfer/route.ts | 129 ------------- apps/web/app/api/links/upsert/route.ts | 3 +- apps/web/ui/domains/domain-card.tsx | 28 +-- apps/web/ui/links/link-controls.tsx | 41 +---- apps/web/ui/modals/transfer-domain-modal.tsx | 135 -------------- apps/web/ui/modals/transfer-link-modal.tsx | 173 ------------------ 10 files changed, 5 insertions(+), 847 deletions(-) delete mode 100644 apps/web/app/(ee)/api/cron/domains/transfer/route.ts delete mode 100644 apps/web/app/(ee)/api/cron/domains/transfer/utils.ts delete mode 100644 apps/web/app/api/domains/[domain]/transfer/route.ts delete mode 100644 apps/web/app/api/links/[linkId]/transfer/route.ts delete mode 100644 apps/web/ui/modals/transfer-domain-modal.tsx delete mode 100644 apps/web/ui/modals/transfer-link-modal.tsx diff --git a/apps/web/app/(ee)/api/cron/domains/transfer/route.ts b/apps/web/app/(ee)/api/cron/domains/transfer/route.ts deleted file mode 100644 index 196abca6b18..00000000000 --- a/apps/web/app/(ee)/api/cron/domains/transfer/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { linkCache } from "@/lib/api/links/cache"; -import { qstash } from "@/lib/cron"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { recordLink } from "@/lib/tinybird"; -import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { sendDomainTransferredEmail } from "./utils"; - -const schema = z.object({ - currentWorkspaceId: z.string(), - newWorkspaceId: z.string(), - domain: z.string(), -}); - -export const dynamic = "force-dynamic"; - -export async function POST(req: Request) { - try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - - const { currentWorkspaceId, newWorkspaceId, domain } = schema.parse( - JSON.parse(rawBody), - ); - - const links = await prisma.link.findMany({ - where: { domain, projectId: currentWorkspaceId }, - take: 100, - orderBy: { - createdAt: "desc", - }, - }); - - // No remaining links to transfer - if (!links || links.length === 0) { - // Send email to the owner of the current workspace - const linksCount = await prisma.link.count({ - where: { domain, projectId: newWorkspaceId }, - }); - - await sendDomainTransferredEmail({ - domain, - currentWorkspaceId, - newWorkspaceId, - linksCount, - }); - } else { - // Transfer links to the new workspace - const linkIds = links.map((link) => link.id); - - await Promise.all([ - prisma.link.updateMany({ - where: { - domain, - projectId: currentWorkspaceId, - id: { - in: linkIds, - }, - }, - data: { - projectId: newWorkspaceId, - folderId: null, - }, - }), - - prisma.linkTag.deleteMany({ - where: { linkId: { in: linkIds } }, - }), - - // Update links in redis - linkCache.mset( - links.map((link) => ({ ...link, projectId: newWorkspaceId })), - ), - - // Remove the webhooks associated with the links - prisma.linkWebhook.deleteMany({ - where: { linkId: { in: linkIds } }, - }), - - recordLink( - links.map((link) => ({ - ...link, - projectId: newWorkspaceId, - folderId: null, - })), - ), - ]); - - // wait 500 ms before making another request - await new Promise((resolve) => setTimeout(resolve, 500)); - - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/transfer`, - body: { - currentWorkspaceId, - newWorkspaceId, - domain, - }, - }); - } - - return NextResponse.json({ - response: "success", - }); - } catch (error) { - await log({ - message: `Error transferring domain: ${error.message}`, - type: "cron", - }); - - return handleAndReturnErrorResponse(error); - } -} diff --git a/apps/web/app/(ee)/api/cron/domains/transfer/utils.ts b/apps/web/app/(ee)/api/cron/domains/transfer/utils.ts deleted file mode 100644 index 31d3602f6dc..00000000000 --- a/apps/web/app/(ee)/api/cron/domains/transfer/utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { sendEmail } from "@dub/email"; -import DomainTransferred from "@dub/email/templates/domain-transferred"; -import { prisma } from "@dub/prisma"; - -// Send email to the owner after the domain transfer is completed -export const sendDomainTransferredEmail = async ({ - domain, - currentWorkspaceId, - newWorkspaceId, - linksCount, -}: { - domain: string; - currentWorkspaceId: string; - newWorkspaceId: string; - linksCount: number; -}) => { - const currentWorkspace = await prisma.project.findUnique({ - where: { - id: currentWorkspaceId, - }, - select: { - users: { - where: { - role: "owner", - }, - select: { - user: { - select: { - email: true, - }, - }, - }, - }, - }, - }); - - const newWorkspace = await prisma.project.findUniqueOrThrow({ - where: { - id: newWorkspaceId, - }, - select: { - name: true, - slug: true, - }, - }); - - const ownerEmail = currentWorkspace?.users[0]?.user?.email!; - - await sendEmail({ - subject: "Domain transfer completed", - to: ownerEmail, - react: DomainTransferred({ - email: ownerEmail, - domain, - newWorkspace, - linksCount, - }), - }); -}; diff --git a/apps/web/app/api/domains/[domain]/transfer/route.ts b/apps/web/app/api/domains/[domain]/transfer/route.ts deleted file mode 100644 index 14a6cd201ed..00000000000 --- a/apps/web/app/api/domains/[domain]/transfer/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { getAnalytics } from "@/lib/analytics/get-analytics"; -import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw"; -import { transformDomain } from "@/lib/api/domains/transform-domain"; -import { DubApiError } from "@/lib/api/errors"; -import { withWorkspace } from "@/lib/auth"; -import { qstash } from "@/lib/cron"; -import { ratelimit } from "@/lib/upstash"; -import { transferDomainBodySchema } from "@/lib/zod/schemas/domains"; -import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; -import { NextResponse } from "next/server"; - -// POST /api/domains/[domain]/transfer – transfer a domain to another workspace -export const POST = withWorkspace( - async ({ req, headers, session, params, workspace }) => { - const { slug: domain, registeredDomain } = await getDomainOrThrow({ - workspace, - domain: params.domain, - dubDomainChecks: true, - }); - - if (registeredDomain) { - throw new DubApiError({ - code: "forbidden", - message: "You cannot transfer a Dub-provisioned domain.", - }); - } - - const { newWorkspaceId } = transferDomainBodySchema.parse(await req.json()); - - if (newWorkspaceId === workspace.id) { - throw new DubApiError({ - code: "bad_request", - message: "Please select another workspace to transfer the domain to.", - }); - } - - // Allow only 1 domain transfer per workspace per hour - const { success } = await ratelimit(1, "1 h").limit( - `domain-transfer:${workspace.id}`, - ); - - if (!success) { - throw new DubApiError({ - code: "rate_limit_exceeded", - message: "Too many requests. Please try again later.", - }); - } - - const newWorkspace = await prisma.project.findUnique({ - where: { id: newWorkspaceId }, - select: { - plan: true, - linksUsage: true, - linksLimit: true, - domainsLimit: true, - name: true, - users: { - where: { - userId: session.user.id, - }, - select: { - role: true, - }, - }, - domains: { - select: { - slug: true, - }, - }, - }, - }); - - if (!newWorkspace || newWorkspace.users.length === 0) { - throw new DubApiError({ - code: "not_found", - message: "New workspace not found. Make sure you have access to it.", - }); - } - - if (newWorkspace.domains.length >= newWorkspace.domainsLimit) { - throw new DubApiError({ - code: "exceeded_limit", - message: `Workspace ${newWorkspace.name} has reached its domain limit (${newWorkspace.domainsLimit}). You need to upgrade it to accommodate more domains.`, - }); - } - - if (newWorkspace.linksUsage >= newWorkspace.linksLimit) { - throw new DubApiError({ - code: "exceeded_limit", - message: `Workspace ${newWorkspace.name} has reached its link limit.`, - }); - } - - const linksCount = await prisma.link.count({ - where: { domain, projectId: workspace.id }, - }); - - if (newWorkspace.linksUsage + linksCount > newWorkspace.linksLimit) { - throw new DubApiError({ - code: "exceeded_limit", - message: `Workspace ${newWorkspace.name} doesn't have enough space to accommodate the links of the domain ${domain}.`, - }); - } - - const { clicks: totalLinkClicks } = await getAnalytics({ - domain, - event: "clicks", - groupBy: "count", - workspaceId: workspace.id, - interval: "30d", - }); - - // Update the domain to use the new workspace - const [domainResponse] = await Promise.all([ - prisma.domain.update({ - where: { slug: domain, projectId: workspace.id }, - data: { - projectId: newWorkspaceId, - primary: newWorkspace.domains.length === 0, - }, - include: { - registeredDomain: true, - }, - }), - prisma.project.update({ - where: { id: workspace.id }, - data: { - usage: { - set: Math.max(workspace.usage - totalLinkClicks, 0), - }, - linksUsage: { - set: Math.max(workspace.linksUsage - linksCount, 0), - }, - }, - }), - prisma.project.update({ - where: { id: newWorkspaceId }, - data: { - usage: { - increment: totalLinkClicks, - }, - linksUsage: { - increment: linksCount, - }, - }, - }), - ]); - - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/transfer`, - body: { - currentWorkspaceId: workspace.id, - newWorkspaceId, - domain, - linksCount, - }, - }); - - return NextResponse.json(transformDomain(domainResponse), { headers }); - }, - { - requiredPermissions: ["domains.write"], - }, -); diff --git a/apps/web/app/api/links/[linkId]/route.ts b/apps/web/app/api/links/[linkId]/route.ts index 3b97169cd95..0bec8cba1d5 100644 --- a/apps/web/app/api/links/[linkId]/route.ts +++ b/apps/web/app/api/links/[linkId]/route.ts @@ -140,8 +140,7 @@ export const PATCH = withWorkspace( if (updatedLink.projectId !== link?.projectId) { throw new DubApiError({ code: "forbidden", - message: - "Transferring links to another workspace is only allowed via the /links/[linkId]/transfer endpoint.", + message: "You cannot transfer links to another workspace.", }); } diff --git a/apps/web/app/api/links/[linkId]/transfer/route.ts b/apps/web/app/api/links/[linkId]/transfer/route.ts deleted file mode 100644 index 4131479c3f8..00000000000 --- a/apps/web/app/api/links/[linkId]/transfer/route.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { getAnalytics } from "@/lib/analytics/get-analytics"; -import { DubApiError } from "@/lib/api/errors"; -import { linkCache } from "@/lib/api/links/cache"; -import { getLinkOrThrow } from "@/lib/api/links/get-link-or-throw"; -import { normalizeWorkspaceId } from "@/lib/api/workspaces/workspace-id"; -import { withWorkspace } from "@/lib/auth"; -import { verifyFolderAccess } from "@/lib/folder/permissions"; -import { recordLink } from "@/lib/tinybird"; -import { prisma } from "@dub/prisma"; -import { waitUntil } from "@vercel/functions"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - -const transferLinkBodySchema = z.object({ - newWorkspaceId: z - .string() - .min(1, "Missing new workspace ID.") - .transform((v) => normalizeWorkspaceId(v)), -}); - -// POST /api/links/[linkId]/transfer – transfer a link to another workspace -export const POST = withWorkspace( - async ({ req, headers, session, params, workspace }) => { - const link = await getLinkOrThrow({ - workspaceId: workspace.id, - linkId: params.linkId, - }); - - if (link.folderId) { - await verifyFolderAccess({ - workspace, - userId: session.user.id, - folderId: link.folderId, - requiredPermission: "folders.links.write", - }); - } - - const { newWorkspaceId } = transferLinkBodySchema.parse(await req.json()); - - const newWorkspace = await prisma.project.findUnique({ - where: { id: newWorkspaceId }, - select: { - linksUsage: true, - linksLimit: true, - users: { - where: { - userId: session.user.id, - }, - select: { - role: true, - }, - }, - }, - }); - - if (!newWorkspace || newWorkspace.users.length === 0) { - throw new DubApiError({ - code: "not_found", - message: "New workspace not found.", - }); - } - - if (newWorkspace.linksUsage >= newWorkspace.linksLimit) { - throw new DubApiError({ - code: "forbidden", - message: "New workspace has reached its link limit.", - }); - } - - const { clicks: linkClicks } = await getAnalytics({ - event: "clicks", - groupBy: "count", - linkId: link.id, - interval: "30d", - }); - - const updatedLink = await prisma.link.update({ - where: { - id: link.id, - }, - data: { - projectId: newWorkspaceId, - // remove tags when transferring link - tags: { - deleteMany: {}, - }, - // remove folder when transferring link - folderId: null, - }, - }); - - waitUntil( - Promise.all([ - linkCache.set(updatedLink), - - recordLink(updatedLink), - - // increment new workspace usage - prisma.project.update({ - where: { - id: newWorkspaceId, - }, - data: { - usage: { - increment: linkClicks, - }, - linksUsage: { - increment: 1, - }, - }, - }), - - // Remove the webhooks associated with the link - prisma.linkWebhook.deleteMany({ - where: { - linkId: link.id, - }, - }), - ]), - ); - - return NextResponse.json(updatedLink, { - headers, - }); - }, - { - requiredPermissions: ["links.write"], - }, -); diff --git a/apps/web/app/api/links/upsert/route.ts b/apps/web/app/api/links/upsert/route.ts index 5789fb1d6d1..8e8a8fefd65 100644 --- a/apps/web/app/api/links/upsert/route.ts +++ b/apps/web/app/api/links/upsert/route.ts @@ -109,8 +109,7 @@ export const PUT = withWorkspace( if (updatedLink.projectId !== link?.projectId) { throw new DubApiError({ code: "forbidden", - message: - "Transferring links to another workspace is only allowed via the /links/[linkId]/transfer endpoint.", + message: "You cannot transfer links to another workspace.", }); } diff --git a/apps/web/ui/domains/domain-card.tsx b/apps/web/ui/domains/domain-card.tsx index 8c583b695ea..a20ed449e60 100644 --- a/apps/web/ui/domains/domain-card.tsx +++ b/apps/web/ui/domains/domain-card.tsx @@ -38,7 +38,7 @@ import { nFormatter, } from "@dub/utils"; import { isPast } from "date-fns"; -import { Archive, ChevronDown, FolderInput, QrCode } from "lucide-react"; +import { Archive, ChevronDown, QrCode } from "lucide-react"; import { motion } from "motion/react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; @@ -52,7 +52,6 @@ import { useDomainAutoRenewalModal } from "../modals/domain-auto-renewal-modal"; import { useLinkBuilder } from "../modals/link-builder"; import { useLinkQRModal } from "../modals/link-qr-modal"; import { usePrimaryDomainModal } from "../modals/primary-domain-modal"; -import { useTransferDomainModal } from "../modals/transfer-domain-modal"; import { DomainCardTitleColumn } from "./domain-card-title-column"; import DomainConfiguration from "./domain-configuration"; @@ -350,11 +349,6 @@ function DomainCardMenu({ props, }); - const { setShowTransferDomainModal, TransferDomainModal } = - useTransferDomainModal({ - props, - }); - const { setShowPrimaryDomainModal, PrimaryDomainModal } = usePrimaryDomainModal({ props, @@ -387,8 +381,6 @@ function DomainCardMenu({ }); }; - const activeDomainsCount = activeWorkspaceDomains?.length || 0; - return ( <> @@ -397,7 +389,6 @@ function DomainCardMenu({ - )} - {!isDubProvisioned && ( -