diff --git a/packages/api/src/lib/vercel.ts b/packages/api/src/lib/vercel.ts new file mode 100644 index 00000000..76a694b9 --- /dev/null +++ b/packages/api/src/lib/vercel.ts @@ -0,0 +1,81 @@ +import { TRPCError } from "@trpc/server"; + +import { env } from "../env"; + +// Vercel domain helpers — transport-layer external integrations that +// don't belong in the service layer. +export async function addDomainToVercel(domain: string) { + const response = await fetch( + `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, + { + body: JSON.stringify({ name: domain }), + headers: { + Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + const code = error?.error?.code; + console.error("Failed to add domain to Vercel:", { domain, error }); + throw toDomainError(domain, code); + } + + return response.json(); +} + +// Vercel messages leak internal project details, so map known codes to our own copy. +function toDomainError(domain: string, code?: string): TRPCError { + switch (code) { + case "domain_already_in_use": + return new TRPCError({ + code: "CONFLICT", + message: `The domain '${domain}' is already in use by another status page. Remove it there first or contact support.`, + }); + case "invalid_domain": + case "not_found": + return new TRPCError({ + code: "BAD_REQUEST", + message: `The domain '${domain}' is invalid.`, + }); + case "forbidden": + case "domain_taken": + return new TRPCError({ + code: "FORBIDDEN", + message: `The domain '${domain}' belongs to another team on our hosting provider. Contact support if you own it.`, + }); + default: + return new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to add custom domain. Please try again. If it continues, contact support.", + }); + } +} + +export async function removeDomainFromVercel(domain: string) { + const response = await fetch( + `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${env.TEAM_ID_VERCEL}`, + { + headers: { + Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, + }, + method: "DELETE", + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.error("Failed to remove domain from Vercel:", { domain, error }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to remove custom domain. Please try again. If it continues, contact support.", + }); + } + + return response.json(); +} diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index 9dff33a8..11cbdf3e 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -31,88 +31,10 @@ import { import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { env } from "../env"; +import { addDomainToVercel, removeDomainFromVercel } from "../lib/vercel"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -// Vercel domain helpers — transport-layer external integrations that -// don't belong in the service layer. -async function addDomainToVercel(domain: string) { - const response = await fetch( - `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, - { - body: JSON.stringify({ name: domain }), - headers: { - Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, - "Content-Type": "application/json", - }, - method: "POST", - }, - ); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - const code = error?.error?.code; - console.error("Failed to add domain to Vercel:", { domain, error }); - throw toDomainError(domain, code); - } - - return response.json(); -} - -// Vercel messages leak internal project details, so map known codes to our own copy. -function toDomainError(domain: string, code?: string): TRPCError { - switch (code) { - case "domain_already_in_use": - return new TRPCError({ - code: "CONFLICT", - message: `The domain '${domain}' is already in use by another status page. Remove it there first or contact support.`, - }); - case "invalid_domain": - case "not_found": - return new TRPCError({ - code: "BAD_REQUEST", - message: `The domain '${domain}' is invalid.`, - }); - case "forbidden": - case "domain_taken": - return new TRPCError({ - code: "FORBIDDEN", - message: `The domain '${domain}' belongs to another team on our hosting provider. Contact support if you own it.`, - }); - default: - return new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to add custom domain. Please try again. If it continues, contact support.", - }); - } -} - -async function removeDomainFromVercel(domain: string) { - const response = await fetch( - `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${env.TEAM_ID_VERCEL}`, - { - headers: { - Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, - }, - method: "DELETE", - }, - ); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - console.error("Failed to remove domain from Vercel:", { domain, error }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to remove custom domain. Please try again. If it continues, contact support.", - }); - } - - return response.json(); -} - export const pageRouter = createTRPCRouter({ create: protectedProcedure .meta({ track: Events.CreatePage, trackProps: ["slug"] }) diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 192f5e5c..c928b8ee 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -15,6 +15,7 @@ import { TRPCError } from "@trpc/server"; import type Stripe from "stripe"; import { z } from "zod"; +import { removeDomainFromVercel } from "../../lib/vercel"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { stripe } from "./shared"; import { buildLimitsFromSubscription } from "./utils"; @@ -223,6 +224,8 @@ export const webhookRouter = createTRPCRouter({ return; } + let customDomains: string[] = []; + const _workspace = await opts.ctx.db.transaction(async (tx) => { const _workspace = await tx .update(workspace) @@ -266,11 +269,15 @@ export const webhookRouter = createTRPCRouter({ } const statusPages = await tx - .select({ id: page.id }) + .select({ id: page.id, customDomain: page.customDomain }) .from(page) .where(eq(page.workspaceId, workspaceId)) .orderBy(asc(page.createdAt)); + customDomains = statusPages + .map((p) => p.customDomain) + .filter((domain) => domain !== ""); + for (const p of statusPages.slice(1)) { await tx.delete(page).where(eq(page.id, p.id)).run(); } @@ -331,6 +338,17 @@ export const webhookRouter = createTRPCRouter({ }); } + // Free plan has no custom-domain feature — release the domains on Vercel + // so they stop routing to the status page. Best-effort after commit: the + // downgrade must not fail (and get retried by Stripe) on a Vercel error. + for (const domain of customDomains) { + try { + await removeDomainFromVercel(domain); + } catch (_e) { + // already logged inside removeDomainFromVercel + } + } + const workspaceId = _workspace[0].id; const customer = await stripe.customers.retrieve(customerId);