diff --git a/apps/web/app/api/admin/links/ban/route.ts b/apps/web/app/api/admin/links/ban/route.ts index 97f5a9c3ee..07a8c82beb 100644 --- a/apps/web/app/api/admin/links/ban/route.ts +++ b/apps/web/app/api/admin/links/ban/route.ts @@ -1,6 +1,6 @@ +import { linkCache } from "@/lib/api/links/cache"; import { withAdmin } from "@/lib/auth"; import { updateConfig } from "@/lib/edge-config"; -import { formatRedisLink, redis } from "@/lib/upstash"; import { domainKeySchema } from "@/lib/zod/schemas/links"; import { prisma } from "@dub/prisma"; import { @@ -34,12 +34,9 @@ export const DELETE = withAdmin(async ({ searchParams }) => { projectId: LEGAL_WORKSPACE_ID, }, }), - redis.hset(link.domain.toLowerCase(), { - [link.key.toLowerCase()]: { - ...(await formatRedisLink(link)), - projectId: LEGAL_WORKSPACE_ID, - }, - }), + + linkCache.set({ ...link, projectId: LEGAL_WORKSPACE_ID }), + urlDomain && updateConfig({ key: "domains", diff --git a/apps/web/app/api/admin/links/route.ts b/apps/web/app/api/admin/links/route.ts index 13a8229f27..406da4eee6 100644 --- a/apps/web/app/api/admin/links/route.ts +++ b/apps/web/app/api/admin/links/route.ts @@ -19,9 +19,16 @@ export const GET = withAdmin(async ({ searchParams }) => { const response = await prisma.link.findMany({ where: { - userId: { - not: LEGAL_USER_ID, - }, + OR: [ + { + userId: { + not: LEGAL_USER_ID, + }, + }, + { + userId: null, + }, + ], ...(domain ? { domain } : { diff --git a/apps/web/app/api/cron/cleanup/route.ts b/apps/web/app/api/cron/cleanup/route.ts index 8512a43b33..aa71130cea 100644 --- a/apps/web/app/api/cron/cleanup/route.ts +++ b/apps/web/app/api/cron/cleanup/route.ts @@ -1,4 +1,4 @@ -import { deleteDomainAndLinks } from "@/lib/api/domains"; +import { markDomainAsDeleted } from "@/lib/api/domains"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; @@ -71,7 +71,12 @@ export async function GET(req: Request) { // Delete the domains if (domains.length > 0) { await Promise.all( - domains.map((domain) => deleteDomainAndLinks(domain.slug)), + domains.map(({ slug }) => + markDomainAsDeleted({ + domain: slug, + workspaceId: E2E_WORKSPACE_ID, + }), + ), ); } diff --git a/apps/web/app/api/cron/domains/delete/route.ts b/apps/web/app/api/cron/domains/delete/route.ts new file mode 100644 index 0000000000..e7b0313fb3 --- /dev/null +++ b/apps/web/app/api/cron/domains/delete/route.ts @@ -0,0 +1,132 @@ +import { queueDomainDeletion } from "@/lib/api/domains/queue"; +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { linkCache } from "@/lib/api/links/cache"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { storage } from "@/lib/storage"; +import { recordLink } from "@/lib/tinybird/record-link"; +import { prisma } from "@dub/prisma"; +import { R2_URL } from "@dub/utils"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + domain: z.string(), + workspaceId: z.string(), +}); + +// POST /api/cron/domains/delete +export async function POST(req: Request) { + try { + const body = await req.json(); + + await verifyQstashSignature(req, body); + + const { domain, workspaceId } = schema.parse(body); + + const domainRecord = await prisma.domain.findUnique({ + where: { + slug: domain, + }, + }); + + if (!domainRecord) { + return new Response(`Domain ${domain} not found. Skipping...`); + } + + const links = await prisma.link.findMany({ + where: { + domain, + }, + include: { + tags: true, + }, + take: 100, // TODO: We can adjust this number based on the performance + }); + + if (links.length === 0) { + return new Response("No more links to delete. Exiting..."); + } + + const response = await Promise.allSettled([ + // Remove the link from Redis + linkCache.deleteMany(links), + + // Record link in the Tinybird + recordLink( + links.map((link) => ({ + link_id: link.id, + domain: link.domain, + key: link.key, + url: link.url, + tag_ids: link.tags.map((tag) => tag.id), + folder_id: link.folderId, + program_id: link.programId ?? "", + workspace_id: workspaceId, + created_at: link.createdAt, + deleted: true, + })), + ), + + // Remove image from R2 storage if it exists + links + .filter((link) => link.image?.startsWith(`${R2_URL}/images/${link.id}`)) + .map((link) => storage.delete(link.image!.replace(`${R2_URL}/`, ""))), + + // Remove the link from MySQL + prisma.link.deleteMany({ + where: { + id: { in: links.map((link) => link.id) }, + }, + }), + ]); + + console.log(response); + + response.forEach((promise) => { + if (promise.status === "rejected") { + console.error("deleteDomainAndLinks", { + reason: promise.reason, + domain, + workspaceId, + }); + } + }); + + const remainingLinks = await prisma.link.count({ + where: { + domain, + }, + }); + + console.log("remainingLinks", remainingLinks); + + if (remainingLinks > 0) { + await queueDomainDeletion({ + workspaceId, + domain, + delay: 2, + }); + return new Response( + `Deleted ${links.length} links, ${remainingLinks} remaining. Starting next batch...`, + ); + } + + // After all links are deleted, delete the domain and image + await Promise.all([ + prisma.domain.delete({ + where: { + slug: domain, + }, + }), + domainRecord.logo && + storage.delete(domainRecord.logo.replace(`${R2_URL}/`, "")), + ]); + + return new Response( + `Deleted ${links.length} links, no more links remaining. Domain deleted.`, + ); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/app/api/cron/domains/transfer/route.ts b/apps/web/app/api/cron/domains/transfer/route.ts index c0899e0dcb..c94751a52e 100644 --- a/apps/web/app/api/cron/domains/transfer/route.ts +++ b/apps/web/app/api/cron/domains/transfer/route.ts @@ -1,4 +1,5 @@ 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"; @@ -6,7 +7,7 @@ import z from "@/lib/zod"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; import { NextResponse } from "next/server"; -import { sendDomainTransferredEmail, updateLinksInRedis } from "./utils"; +import { sendDomainTransferredEmail } from "./utils"; const schema = z.object({ currentWorkspaceId: z.string(), @@ -60,7 +61,10 @@ export async function POST(req: Request) { where: { linkId: { in: linkIds } }, }), - updateLinksInRedis({ links, newWorkspaceId, domain }), + // Update links in redis + linkCache.mset( + links.map((link) => ({ ...link, projectId: newWorkspaceId })), + ), // Remove the webhooks associated with the links prisma.linkWebhook.deleteMany({ diff --git a/apps/web/app/api/cron/domains/transfer/utils.ts b/apps/web/app/api/cron/domains/transfer/utils.ts index f43ad4b97f..1776a7b474 100644 --- a/apps/web/app/api/cron/domains/transfer/utils.ts +++ b/apps/web/app/api/cron/domains/transfer/utils.ts @@ -1,42 +1,7 @@ -import { formatRedisLink, redis } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; -import { Link } from "@dub/prisma/client"; import { sendEmail } from "emails"; import DomainTransferred from "emails/domain-transferred"; -// Update links in redis -export const updateLinksInRedis = async ({ - newWorkspaceId, - domain, - links, -}: { - newWorkspaceId: string; - domain: string; - links: Link[]; -}) => { - const pipeline = redis.pipeline(); - - const formatedLinks = await Promise.all( - links.map(async (link) => { - return { - ...(await formatRedisLink(link)), - projectId: newWorkspaceId, - key: link.key.toLowerCase(), - }; - }), - ); - - formatedLinks.map((formatedLink) => { - const { key, ...rest } = formatedLink; - - pipeline.hset(domain.toLowerCase(), { - [formatedLink.key]: rest, - }); - }); - - await pipeline.exec(); -}; - // Send email to the owner after the domain transfer is completed export const sendDomainTransferredEmail = async ({ domain, diff --git a/apps/web/app/api/cron/domains/update/route.ts b/apps/web/app/api/cron/domains/update/route.ts new file mode 100644 index 0000000000..d5e641245e --- /dev/null +++ b/apps/web/app/api/cron/domains/update/route.ts @@ -0,0 +1,89 @@ +import { queueDomainUpdate } from "@/lib/api/domains/queue"; +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { linkCache } from "@/lib/api/links/cache"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { recordLink } from "@/lib/tinybird"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + newDomain: z.string(), + oldDomain: z.string(), + workspaceId: z.string(), + page: z.number(), +}); + +const pageSize = 100; + +// POST /api/cron/domains/update +export async function POST(req: Request) { + try { + const body = await req.json(); + + await verifyQstashSignature(req, body); + + const { newDomain, oldDomain, workspaceId, page } = schema.parse(body); + + const newDomainRecord = await prisma.domain.findUnique({ + where: { + slug: newDomain, + }, + }); + + if (!newDomainRecord) { + return new Response(`Domain ${newDomain} not found. Skipping update...`); + } + + const links = await prisma.link.findMany({ + where: { + domain: newDomain, + }, + include: { + tags: true, + }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + if (links.length === 0) { + return new Response("No more links to update. Exiting..."); + } + + await Promise.all([ + // rename redis keys + linkCache.rename({ + links, + oldDomain, + }), + + // update links in Tinybird + recordLink( + links.map((link) => ({ + link_id: link.id, + domain: link.domain, + key: link.key, + url: link.url, + tag_ids: link.tags.map((tag) => tag.tagId), + folder_id: link.folderId, + program_id: link.programId ?? "", + workspace_id: link.projectId, + created_at: link.createdAt, + })), + ), + ]); + + await queueDomainUpdate({ + workspaceId, + oldDomain, + newDomain, + page: page + 1, + delay: 2, + }); + + return new Response("Domain's links updated."); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/app/api/cron/domains/verify/utils.ts b/apps/web/app/api/cron/domains/verify/utils.ts index 6872dd9b3b..277c89944e 100644 --- a/apps/web/app/api/cron/domains/verify/utils.ts +++ b/apps/web/app/api/cron/domains/verify/utils.ts @@ -1,4 +1,4 @@ -import { deleteDomainAndLinks } from "@/lib/api/domains"; +import { markDomainAsDeleted } from "@/lib/api/domains"; import { limiter } from "@/lib/cron/limiter"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; @@ -99,9 +99,13 @@ export const handleDomainUpdates = async ({ type: "cron", }); } + // else, delete the domain return await Promise.allSettled([ - deleteDomainAndLinks(domain).then(async () => { + markDomainAsDeleted({ + domain, + workspaceId: workspace.id, + }).then(async () => { // if the deleted domain was primary, make another domain primary if (primary) { const anotherDomain = await prisma.domain.findFirst({ diff --git a/apps/web/app/api/cron/workspaces/delete/route.ts b/apps/web/app/api/cron/workspaces/delete/route.ts new file mode 100644 index 0000000000..a0bc8413a3 --- /dev/null +++ b/apps/web/app/api/cron/workspaces/delete/route.ts @@ -0,0 +1,110 @@ +import { removeDomainFromVercel } from "@/lib/api/domains"; +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links"; +import { queueWorkspaceDeletion } from "@/lib/api/workspaces"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + workspaceId: z.string(), +}); + +// POST /api/cron/workspaces/delete +export async function POST(req: Request) { + try { + const body = await req.json(); + + await verifyQstashSignature(req, body); + + const { workspaceId } = schema.parse(body); + + const workspace = await prisma.project.findUnique({ + where: { + id: workspaceId, + }, + }); + + if (!workspace) { + return new Response(`Workspace ${workspaceId} not found. Skipping...`); + } + + // Delete links in batches + const links = await prisma.link.findMany({ + where: { + projectId: workspace.id, + }, + include: { + tags: true, + }, + take: 100, // TODO: We can adjust this number based on the performance + }); + + if (links.length > 0) { + const res = await Promise.all([ + prisma.link.deleteMany({ + where: { + id: { + in: links.map((link) => link.id), + }, + }, + }), + + bulkDeleteLinks({ + links, + }), + ]); + + console.log(res); + } + + const remainingLinks = await prisma.link.count({ + where: { + projectId: workspace.id, + }, + }); + + if (remainingLinks > 0) { + await queueWorkspaceDeletion({ + workspaceId: workspace.id, + delay: 2, + }); + + return new Response( + `Deleted ${links.length} links, ${remainingLinks} remaining. Starting next batch...`, + ); + } + + // Delete the custom domains + const domains = await prisma.domain.findMany({ + where: { + projectId: workspace.id, + }, + }); + + if (domains.length > 0) { + await Promise.all([ + prisma.domain.deleteMany({ + where: { + projectId: workspace.id, + }, + }), + + domains.map(({ slug }) => removeDomainFromVercel(slug)), + ]); + } + + // Delete the workspace + await prisma.project.delete({ + where: { id: workspace.id }, + }); + + return new Response( + `Deleted ${links.length} links, no more links remaining. Workspace deleted.`, + ); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/app/api/customers/[id]/route.ts b/apps/web/app/api/customers/[id]/route.ts index 63fedfbfbb..b62a58604e 100644 --- a/apps/web/app/api/customers/[id]/route.ts +++ b/apps/web/app/api/customers/[id]/route.ts @@ -14,10 +14,15 @@ export const GET = withWorkspace( async ({ workspace, params }) => { const { id } = params; - const customer = await getCustomerOrThrow({ - id, - workspaceId: workspace.id, - }); + const customer = await getCustomerOrThrow( + { + id, + workspaceId: workspace.id, + }, + { + expand: ["link"], + }, + ); return NextResponse.json(CustomerSchema.parse(customer)); }, @@ -35,32 +40,29 @@ export const PATCH = withWorkspace( await parseRequestBody(req), ); - await getCustomerOrThrow({ + const customer = await getCustomerOrThrow({ id, workspaceId: workspace.id, }); try { - const customer = await prisma.customer.update({ + const updatedCustomer = await prisma.customer.update({ where: { - id, + id: customer.id, }, data: { name, email, avatar, externalId }, + include: { + link: true, + }, }); - return NextResponse.json(CustomerSchema.parse(customer)); + return NextResponse.json(CustomerSchema.parse(updatedCustomer)); } catch (error) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: "A customer with this external ID already exists.", }); - } else if (error.code === "P2025") { - throw new DubApiError({ - code: "not_found", - message: - "Customer not found. Make sure you're using the correct external ID.", - }); } throw new DubApiError({ @@ -79,34 +81,20 @@ export const DELETE = withWorkspace( async ({ workspace, params }) => { const { id } = params; - await getCustomerOrThrow({ + const customer = await getCustomerOrThrow({ id, workspaceId: workspace.id, }); - try { - await prisma.customer.delete({ - where: { - id, - }, - }); - - return NextResponse.json({ - id, - }); - } catch (error) { - if (error.code === "P2025") { - throw new DubApiError({ - code: "not_found", - message: "Customer not found", - }); - } + await prisma.customer.delete({ + where: { + id: customer.id, + }, + }); - throw new DubApiError({ - code: "unprocessable_entity", - message: error.message, - }); - } + return NextResponse.json({ + id: customer.id, + }); }, { requiredAddOn: "conversion", diff --git a/apps/web/app/api/customers/route.ts b/apps/web/app/api/customers/route.ts index 017c41e8b4..6d0b313d48 100644 --- a/apps/web/app/api/customers/route.ts +++ b/apps/web/app/api/customers/route.ts @@ -48,6 +48,9 @@ export const POST = withWorkspace( projectId: workspace.id, projectConnectId: workspace.stripeConnectId, }, + include: { + link: true, + }, }); return NextResponse.json(CustomerSchema.parse(customer), { diff --git a/apps/web/app/api/domains/[domain]/route.ts b/apps/web/app/api/domains/[domain]/route.ts index d911e446a8..064294a7dc 100644 --- a/apps/web/app/api/domains/[domain]/route.ts +++ b/apps/web/app/api/domains/[domain]/route.ts @@ -1,16 +1,15 @@ import { addDomainToVercel, - deleteDomainAndLinks, + markDomainAsDeleted, removeDomainFromVercel, validateDomain, } from "@/lib/api/domains"; import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw"; +import { queueDomainUpdate } from "@/lib/api/domains/queue"; import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { storage } from "@/lib/storage"; -import { recordLink } from "@/lib/tinybird"; -import { redis } from "@/lib/upstash"; import { DomainSchema, updateDomainBodySchema, @@ -137,33 +136,15 @@ export const PATCH = withWorkspace( await Promise.all([ // remove old domain from Vercel removeDomainFromVercel(domain), - // rename redis key - redis.rename(domain.toLowerCase(), newDomain.toLowerCase()), - ]); - const allLinks = await prisma.link.findMany({ - where: { - domain: newDomain, - }, - include: { - tags: true, - }, - }); - - // update all links in Tinybird - recordLink( - allLinks.map((link) => ({ - link_id: link.id, - domain: link.domain, - key: link.key, - url: link.url, - tag_ids: link.tags.map((tag) => tag.tagId), - folder_id: link.folderId, - program_id: link.programId ?? "", - workspace_id: link.projectId, - created_at: link.createdAt, - })), - ); + // trigger the queue to rename the redis keys and update the links in Tinybird + queueDomainUpdate({ + workspaceId: workspace.id, + oldDomain: domain, + newDomain: newDomain, + page: 1, + }), + ]); } })(), ); @@ -191,7 +172,10 @@ export const DELETE = withWorkspace( }); } - await deleteDomainAndLinks(domain); + await markDomainAsDeleted({ + domain, + workspaceId: workspace.id, + }); return NextResponse.json({ slug: domain }); }, diff --git a/apps/web/app/api/domains/[domain]/transfer/route.ts b/apps/web/app/api/domains/[domain]/transfer/route.ts index cbb3116f4a..af4446b3b4 100644 --- a/apps/web/app/api/domains/[domain]/transfer/route.ts +++ b/apps/web/app/api/domains/[domain]/transfer/route.ts @@ -23,7 +23,7 @@ export const POST = withWorkspace( if (registeredDomain) { throw new DubApiError({ code: "forbidden", - message: "You cannot delete a Dub-provisioned domain.", + message: "You cannot transfer a Dub-provisioned domain.", }); } @@ -108,6 +108,9 @@ export const POST = withWorkspace( projectId: newWorkspaceId, primary: newWorkspace.domains.length === 0, }, + include: { + registeredDomain: true, + }, }), prisma.project.update({ where: { id: workspace.id }, diff --git a/apps/web/app/api/links/[linkId]/transfer/route.ts b/apps/web/app/api/links/[linkId]/transfer/route.ts index db0f328427..5ad1e676f1 100644 --- a/apps/web/app/api/links/[linkId]/transfer/route.ts +++ b/apps/web/app/api/links/[linkId]/transfer/route.ts @@ -1,10 +1,10 @@ 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 { withWorkspace } from "@/lib/auth"; import { checkFolderPermission } from "@/lib/folder/permissions"; import { recordLink } from "@/lib/tinybird"; -import { formatRedisLink, redis } from "@/lib/upstash"; import z from "@/lib/zod"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; @@ -91,12 +91,8 @@ export const POST = withWorkspace( waitUntil( Promise.all([ - redis.hset(link.domain.toLowerCase(), { - [link.key.toLowerCase()]: await formatRedisLink({ - ...link, - projectId: newWorkspaceId, - }), - }), + linkCache.set({ ...link, projectId: newWorkspaceId }), + recordLink({ link_id: link.id, domain: link.domain, diff --git a/apps/web/app/api/links/bulk/route.ts b/apps/web/app/api/links/bulk/route.ts index a397e1e25a..db5a774082 100644 --- a/apps/web/app/api/links/bulk/route.ts +++ b/apps/web/app/api/links/bulk/route.ts @@ -162,7 +162,7 @@ export const POST = withWorkspace( const webhookIds = validLinks .map((link) => link.webhookIds) .flat() - .filter((id): id is string => id !== null); + .filter(Boolean) as string[]; const webhooks = await prisma.webhook.findMany({ where: { projectId: workspace.id, id: { in: webhookIds } }, diff --git a/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts index 421ea6676c..828e5e4c33 100644 --- a/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts @@ -22,6 +22,10 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { return "Customer ID not found in Stripe checkout session metadata, skipping..."; } + if (charge.amount_total === 0) { + return `Checkout session completed for customer with external ID ${dubCustomerId} and invoice ID ${invoiceId} but amount is 0, skipping...`; + } + let customer: Customer; try { // Update customer with stripe customerId if exists diff --git a/apps/web/app/api/stripe/integration/webhook/customer-created.ts b/apps/web/app/api/stripe/integration/webhook/customer-created.ts index b1daa4928e..1be941f234 100644 --- a/apps/web/app/api/stripe/integration/webhook/customer-created.ts +++ b/apps/web/app/api/stripe/integration/webhook/customer-created.ts @@ -35,8 +35,8 @@ export async function customerCreated(event: Stripe.Event) { }, }); - if (!link) { - return `Link with ID ${linkId} not found, skipping...`; + if (!link || !link.projectId) { + return `Link with ID ${linkId} not found or does not have a project, skipping...`; } // Check the customer is not already created @@ -61,15 +61,11 @@ export async function customerCreated(event: Stripe.Event) { stripeCustomerId: stripeCustomer.id, projectConnectId: stripeAccountId, externalId, + projectId: link.projectId, linkId, clickId, clickedAt: new Date(clickData.timestamp + "Z"), country: clickData.country, - project: { - connect: { - stripeConnectId: stripeAccountId, - }, - }, }, }); diff --git a/apps/web/app/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/api/stripe/integration/webhook/invoice-paid.ts index cdf80ae48f..d2809e6f18 100644 --- a/apps/web/app/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/api/stripe/integration/webhook/invoice-paid.ts @@ -16,6 +16,10 @@ export async function invoicePaid(event: Stripe.Event) { const stripeCustomerId = invoice.customer as string; const invoiceId = invoice.id; + if (invoice.amount_paid === 0) { + return `Invoice with ID ${invoiceId} has an amount of 0, skipping...`; + } + // Find customer using projectConnectId and stripeCustomerId const customer = await prisma.customer.findFirst({ where: { diff --git a/apps/web/app/api/tokens/route.ts b/apps/web/app/api/tokens/route.ts index d65deb8bec..79d22807e5 100644 --- a/apps/web/app/api/tokens/route.ts +++ b/apps/web/app/api/tokens/route.ts @@ -38,6 +38,7 @@ export const GET = withWorkspace( }, }, orderBy: [{ lastUsed: "desc" }, { createdAt: "desc" }], + take: 100, }); return NextResponse.json(tokenSchema.array().parse(tokens)); diff --git a/apps/web/app/api/track/click/route.ts b/apps/web/app/api/track/click/route.ts index adaf819e58..eba76c6cc2 100644 --- a/apps/web/app/api/track/click/route.ts +++ b/apps/web/app/api/track/click/route.ts @@ -3,7 +3,7 @@ import { parseRequestBody } from "@/lib/api/utils"; import { getLinkViaEdge } from "@/lib/planetscale"; import { recordClick } from "@/lib/tinybird"; import { ratelimit, redis } from "@/lib/upstash"; -import { LOCALHOST_IP, nanoid } from "@dub/utils"; +import { isValidUrl, LOCALHOST_IP, nanoid } from "@dub/utils"; import { ipAddress, waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -18,7 +18,7 @@ const CORS_HEADERS = { // POST /api/track/click – Track a click event from client side export const POST = async (req: Request) => { try { - const { domain, key } = await parseRequestBody(req); + const { domain, key, url } = await parseRequestBody(req); if (!domain || !key) { throw new DubApiError({ @@ -48,6 +48,8 @@ export const POST = async (req: Request) => { }); } + const finalUrl = isValidUrl(url) ? url : link.url; + const cacheKey = `recordClick:${link.id}:${ip}`; let clickId = await redis.get(cacheKey); @@ -60,7 +62,7 @@ export const POST = async (req: Request) => { req, clickId, linkId: link.id, - url: link.url, + url: finalUrl, skipRatelimit: true, }), ); diff --git a/apps/web/app/api/webhooks/[webhookId]/route.ts b/apps/web/app/api/webhooks/[webhookId]/route.ts index 4b6aedc80a..90e16ac055 100644 --- a/apps/web/app/api/webhooks/[webhookId]/route.ts +++ b/apps/web/app/api/webhooks/[webhookId]/route.ts @@ -161,14 +161,7 @@ export const PATCH = withWorkspace( }, }); - const formatedLinks = links.map((link) => { - return { - ...link, - webhookIds: link.webhooks.map(({ webhookId }) => webhookId), - }; - }); - - await linkCache.mset(formatedLinks); + await linkCache.mset(links); } // If the webhook is being changed from workspace level to link level, set the cache @@ -209,16 +202,7 @@ export const PATCH = withWorkspace( }, }); - const formatedLinks = links.map((link) => { - return { - ...link, - ...(link.webhooks.length > 0 && { - webhookIds: link.webhooks.map(({ webhookId }) => webhookId), - }), - }; - }); - - await linkCache.mset(formatedLinks); + await linkCache.mset(links); })(), ); @@ -291,15 +275,8 @@ export const DELETE = withWorkspace( }, }); - const formatedLinks = links.map((link) => { - return { - ...link, - webhookIds: link.webhooks.map((webhook) => webhook.webhookId), - }; - }); - await Promise.all([ - linkCache.mset(formatedLinks), + linkCache.mset(links), webhookCache.delete(webhookId), ]); })(), diff --git a/apps/web/app/api/webhooks/route.ts b/apps/web/app/api/webhooks/route.ts index d2401ea3df..7417abbfd3 100644 --- a/apps/web/app/api/webhooks/route.ts +++ b/apps/web/app/api/webhooks/route.ts @@ -144,17 +144,8 @@ export const POST = withWorkspace( }, }); - const formatedLinks = links.map((link) => { - return { - ...link, - webhookIds: link.webhooks.map((webhook) => webhook.webhookId), - }; - }); - Promise.all([ - ...(links && links.length > 0 - ? [linkCache.mset(formatedLinks), []] - : []), + ...(links && links.length > 0 ? [linkCache.mset(links), []] : []), ...(isLinkLevelWebhook(webhook) ? [webhookCache.set(webhook)] : []), diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/tags/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/tags/page-client.tsx index aa97f583b7..8370f7f018 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/tags/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/tags/page-client.tsx @@ -2,7 +2,6 @@ import useTags from "@/lib/swr/use-tags"; import useTagsCount from "@/lib/swr/use-tags-count"; -import useWorkspace from "@/lib/swr/use-workspace"; import { TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/tags"; import { useAddEditTagModal } from "@/ui/modals/add-edit-tag-modal"; import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; @@ -28,7 +27,6 @@ export const TagsListContext = createContext<{ export default function WorkspaceTagsClient() { const { searchParams, queryParams } = useRouterStuff(); - const { id: workspaceId } = useWorkspace(); const { AddEditTagModal, AddTagButton } = useAddEditTagModal(); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tokens/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tokens/page-client.tsx index 594def8783..0d06027046 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tokens/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/tokens/page-client.tsx @@ -6,31 +6,38 @@ import { TokenProps } from "@/lib/types"; import { useAddEditTokenModal } from "@/ui/modals/add-edit-token-modal"; import { useDeleteTokenModal } from "@/ui/modals/delete-token-modal"; import { useTokenCreatedModal } from "@/ui/modals/token-created-modal"; -import EmptyState from "@/ui/shared/empty-state"; +import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; import { Delete } from "@/ui/shared/icons"; import { - Avatar, - Badge, Button, - LoadingSpinner, + buttonVariants, + Dots, + Icon, + Key, + PenWriting, Popover, - TokenAvatar, + Table, Tooltip, + usePagination, + useTable, } from "@dub/ui"; -import { Key } from "@dub/ui/icons"; -import { fetcher, timeAgo } from "@dub/utils"; -import { Edit3, MoreVertical } from "lucide-react"; +import { cn, DICEBEAR_AVATAR_URL, fetcher, timeAgo } from "@dub/utils"; +import { Command } from "cmdk"; import { useState } from "react"; import useSWR from "swr"; export default function TokensPageClient() { const { id: workspaceId } = useWorkspace(); - const { data: tokens, isLoading } = useSWR( - `/api/tokens?workspaceId=${workspaceId}`, - fetcher, - ); - + const { pagination, setPagination } = usePagination(); const [createdToken, setCreatedToken] = useState(null); + const [selectedToken, setSelectedToken] = useState(null); + + const { + data: tokens, + isLoading, + error, + } = useSWR(`/api/tokens?workspaceId=${workspaceId}`, fetcher); + const { TokenCreatedModal, setShowTokenCreatedModal } = useTokenCreatedModal({ token: createdToken || "", }); @@ -40,76 +47,182 @@ export default function TokensPageClient() { setShowTokenCreatedModal(true); }; - const { AddEditTokenModal, AddTokenButton } = useAddEditTokenModal({ - onTokenCreated, + const { AddEditTokenModal, AddTokenButton, setShowAddEditTokenModal } = + useAddEditTokenModal({ + ...(selectedToken && { + token: { + id: selectedToken.id, + name: selectedToken.name, + isMachine: selectedToken.user.isMachine, + scopes: mapScopesToResource(selectedToken.scopes), + }, + }), + ...(!selectedToken && { onTokenCreated }), + setSelectedToken, + }); + + const { table, ...tableProps } = useTable({ + data: tokens || [], + loading: isLoading && !error && !tokens, + error: error ? "Failed to fetch tokens." : undefined, + columns: [ + { + id: "name", + header: "Name", + accessorKey: "name", + cell: ({ row }) => { + return ( + + + {row.original.name} + + ); + }, + }, + { + id: "permissions", + header: "Permissions", + accessorKey: "scopes", + cell: ({ row }) => scopesToName(row.original.scopes).name, + }, + { + id: "user", + header: "Created", + accessorKey: "user", + cell: ({ row }) => { + return ( +
+ + {row.original.user.name!} + +

+ {new Date(row.original.createdAt).toLocaleDateString("en-us", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+ ); + }, + }, + { + id: "partialKey", + header: "Key", + accessorKey: "partialKey", + cell: ({ row }) => row.original.partialKey, + }, + { + id: "lastUsed", + header: "Last used", + accessorKey: "lastUsed", + cell: ({ row }) => timeAgo(row.original.lastUsed), + }, + + // Menu + { + id: "menu", + enableHiding: false, + minSize: 43, + size: 43, + maxSize: 43, + cell: ({ row }) => ( + { + setSelectedToken(row.original); + setShowAddEditTokenModal(true); + }} + /> + ), + }, + ], + pagination, + onPaginationChange: setPagination, + rowCount: tokens?.length || 0, + thClassName: "border-l-0", + tdClassName: "border-l-0", + onRowClick: (row) => { + setSelectedToken(row.original); + setShowAddEditTokenModal(true); + }, + emptyState: ( + ( + <> + +
+ + )} + addButton={} + learnMoreHref="https://dub.co/docs/api-reference/tokens" + /> + ), + resourceName: (plural) => `token${plural ? "s" : ""}`, }); return ( - <> +
-
-
-
-

Secret keys

-

- These API keys allow other apps to access your workspace. Use it - with caution – do not share your API key with others, or expose it - in the browser or other client-side code.{" "} - - Learn more - -

-
- -
- {isLoading || !tokens ? ( -
- -

Fetching API keys...

-
- ) : tokens.length > 0 ? ( -
-
-
Name
-
Key
-
Last used
-
-
- {tokens.map((token) => ( - - ))} -
-
- ) : ( -
- - -
- )} + +

+ Secret keys +

+

+ These API keys allow other apps to access your workspace. Use it with + caution – do not share your API key with others, or expose it in the + browser or other client-side code.{" "} + + Learn more + +

+ +
+
- + + {tokens?.length !== 0 ? ( + + ) : ( + ( + <> + +
+ + )} + /> + )} +
); } -const TokenRow = (token: TokenProps) => { - const [openPopover, setOpenPopover] = useState(false); - - const { setShowAddEditTokenModal, AddEditTokenModal } = useAddEditTokenModal({ - token: { - id: token.id, - name: token.name, - isMachine: token.user.isMachine, - scopes: mapScopesToResource(token.scopes), - }, - }); +function RowMenuButton({ + token, + onEdit, +}: { + token: TokenProps; + onEdit: () => void; +}) { + const [isOpen, setIsOpen] = useState(false); const { DeleteTokenModal, setShowDeleteTokenModal } = useDeleteTokenModal({ token, @@ -117,99 +230,71 @@ const TokenRow = (token: TokenProps) => { return ( <> - -
-
- -
-
-

{token.name}

- {scopesToName(token.scopes).name} -
-
- - -
-

- {token.user.name || "Anonymous User"} -

-
-

- Created{" "} - {new Date(token.createdAt).toLocaleDateString("en-us", { - month: "short", - day: "numeric", - year: "numeric", - })} -

-
- } - > -
- -
- -

-

- Created {timeAgo(token.createdAt)} -

-
-
-
-
{token.partialKey}
-
- {timeAgo(token.lastUsed)} -
- -
-
- - } - align="end" - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - -
- + + + + + { + setIsOpen(false); + setShowDeleteTokenModal(true); + }} + /> + + + } + align="end" + > + + -