Skip to content

Commit c4ab92b

Browse files
committed
delete workspace
1 parent c430540 commit c4ab92b

File tree

3 files changed

+160
-116
lines changed

3 files changed

+160
-116
lines changed

Diff for: apps/web/app/api/cron/workspaces/delete/route.ts

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { removeDomainFromVercel } from "@/lib/api/domains";
2+
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
3+
import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links";
4+
import { queueWorkspaceDeletion } from "@/lib/api/workspaces";
5+
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
6+
import { prisma } from "@/lib/prisma";
7+
import { z } from "zod";
8+
9+
export const dynamic = "force-dynamic";
10+
11+
const schema = z.object({
12+
workspaceId: z.string(),
13+
});
14+
15+
export async function POST(req: Request) {
16+
try {
17+
const body = await req.json();
18+
19+
await verifyQstashSignature(req, body);
20+
21+
const { workspaceId } = schema.parse(body);
22+
23+
const workspace = await prisma.project.findUnique({
24+
where: {
25+
id: workspaceId,
26+
},
27+
});
28+
29+
if (!workspace) {
30+
return new Response(`Workspace ${workspaceId} not found. Skipping...`);
31+
}
32+
33+
// Delete links in batches
34+
const links = await prisma.link.findMany({
35+
where: {
36+
projectId: workspace.id,
37+
},
38+
include: {
39+
tags: true,
40+
},
41+
take: 100,
42+
});
43+
44+
if (links.length > 0) {
45+
await prisma.link.deleteMany({
46+
where: {
47+
id: {
48+
in: links.map((link) => link.id),
49+
},
50+
},
51+
});
52+
53+
await bulkDeleteLinks({
54+
workspaceId: workspace.id,
55+
links,
56+
});
57+
}
58+
59+
const remainingLinks = await prisma.link.count({
60+
where: {
61+
projectId: workspace.id,
62+
},
63+
});
64+
65+
if (remainingLinks > 0) {
66+
await queueWorkspaceDeletion({
67+
workspaceId: workspace.id,
68+
delay: 2,
69+
});
70+
71+
return new Response("Workspace links deletion queued.");
72+
}
73+
74+
// Delete the custom domains
75+
const domains = await prisma.domain.findMany({
76+
where: {
77+
projectId: workspace.id,
78+
},
79+
});
80+
81+
if (domains.length > 0) {
82+
await Promise.all([
83+
prisma.domain.deleteMany({
84+
where: {
85+
projectId: workspace.id,
86+
},
87+
}),
88+
89+
domains.map(({ slug }) => removeDomainFromVercel(slug)),
90+
]);
91+
}
92+
93+
// Delete the workspace
94+
await prisma.project.delete({
95+
where: { id: workspace.id },
96+
});
97+
98+
return new Response("Workspace deleted.");
99+
} catch (error) {
100+
return handleAndReturnErrorResponse(error);
101+
}
102+
}

Diff for: apps/web/lib/api/domains/delete-domain-links.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ export async function deleteDomainAndLinks({
102102
export async function markDomainAsDeleted({
103103
domain,
104104
workspaceId,
105+
delay,
105106
}: {
106107
domain: string;
107108
workspaceId: string;
109+
delay?: number; // delay the cron job to avoid hitting rate limits
108110
}) {
109111
const links = await prisma.link.updateMany({
110112
where: {
@@ -137,13 +139,14 @@ export async function markDomainAsDeleted({
137139
},
138140
},
139141
}),
140-
141-
queueDomainDeletion({
142-
workspaceId,
143-
domain,
144-
}),
145142
]);
146143

144+
await queueDomainDeletion({
145+
workspaceId,
146+
domain,
147+
delay,
148+
});
149+
147150
response.forEach((promise) => {
148151
if (promise.status === "rejected") {
149152
console.error("markDomainAsDeleted", {

Diff for: apps/web/lib/api/workspaces.ts

+50-111
Original file line numberDiff line numberDiff line change
@@ -3,145 +3,68 @@ import { dub } from "@/lib/dub";
33
import { prisma } from "@/lib/prisma";
44
import { storage } from "@/lib/storage";
55
import { cancelSubscription } from "@/lib/stripe";
6-
import { recordLink } from "@/lib/tinybird";
76
import { WorkspaceProps } from "@/lib/types";
87
import { formatRedisLink, redis } from "@/lib/upstash";
98
import {
9+
APP_DOMAIN_WITH_NGROK,
1010
DUB_DOMAINS_ARRAY,
1111
LEGAL_USER_ID,
1212
LEGAL_WORKSPACE_ID,
1313
R2_URL,
1414
} from "@dub/utils";
15-
import { waitUntil } from "@vercel/functions";
15+
import { qstash } from "../cron";
1616

1717
export async function deleteWorkspace(
1818
workspace: Pick<
1919
WorkspaceProps,
2020
"id" | "slug" | "logo" | "stripeId" | "referralLinkId"
2121
>,
2222
) {
23-
const [customDomains, defaultDomainLinks] = await Promise.all([
24-
prisma.domain.findMany({
23+
await Promise.all([
24+
// Remove the users
25+
prisma.projectUsers.deleteMany({
2526
where: {
2627
projectId: workspace.id,
2728
},
28-
select: {
29-
slug: true,
30-
},
3129
}),
32-
prisma.link.findMany({
30+
31+
// Remove the default workspace
32+
prisma.user.updateMany({
3333
where: {
34-
projectId: workspace.id,
35-
domain: {
36-
in: DUB_DOMAINS_ARRAY,
37-
},
34+
defaultWorkspace: workspace.slug,
3835
},
39-
select: {
40-
id: true,
41-
domain: true,
42-
key: true,
43-
url: true,
44-
tags: {
45-
select: {
46-
tagId: true,
47-
},
48-
},
49-
proxy: true,
50-
image: true,
51-
projectId: true,
52-
createdAt: true,
36+
data: {
37+
defaultWorkspace: null,
5338
},
5439
}),
55-
]);
56-
57-
const response = await prisma.projectUsers.deleteMany({
58-
where: {
59-
projectId: workspace.id,
60-
},
61-
});
62-
63-
waitUntil(
64-
(async () => {
65-
const linksByDomain: Record<string, string[]> = {};
66-
defaultDomainLinks.forEach(async (link) => {
67-
const { domain, key } = link;
68-
69-
if (!linksByDomain[domain]) {
70-
linksByDomain[domain] = [];
71-
}
72-
linksByDomain[domain].push(key.toLowerCase());
73-
});
7440

75-
const pipeline = redis.pipeline();
41+
// Remove the API keys
42+
prisma.restrictedToken.deleteMany({
43+
where: {
44+
projectId: workspace.id,
45+
},
46+
}),
7647

77-
Object.entries(linksByDomain).forEach(([domain, links]) => {
78-
pipeline.hdel(domain.toLowerCase(), ...links);
79-
});
48+
// Cancel the workspace's Stripe subscription
49+
workspace.stripeId && cancelSubscription(workspace.stripeId),
8050

81-
// delete all domains, links, and uploaded images associated with the workspace
82-
await Promise.allSettled([
83-
...customDomains.map(({ slug }) =>
84-
markDomainAsDeleted({
85-
domain: slug,
86-
workspaceId: workspace.id,
87-
}),
88-
),
89-
// delete all default domain links from redis
90-
pipeline.exec(),
91-
// record deletes in Tinybird for default domain links
92-
recordLink(
93-
defaultDomainLinks.map((link) => ({
94-
link_id: link.id,
95-
domain: link.domain,
96-
key: link.key,
97-
url: link.url,
98-
tag_ids: link.tags.map((tag) => tag.tagId),
99-
workspace_id: link.projectId,
100-
created_at: link.createdAt,
101-
deleted: true,
102-
})),
103-
),
104-
// remove all images from R2
105-
...defaultDomainLinks.map(({ id, image }) =>
106-
image && image.startsWith(`${R2_URL}/images/${id}`)
107-
? storage.delete(image.replace(`${R2_URL}/`, ""))
108-
: Promise.resolve(),
109-
),
110-
]);
51+
// Delete workspace logo if it's a custom logo stored in R2
52+
workspace.logo &&
53+
workspace.logo.startsWith(`${R2_URL}/logos/${workspace.id}`) &&
54+
storage.delete(workspace.logo.replace(`${R2_URL}/`, "")),
11155

112-
await Promise.allSettled([
113-
// delete workspace logo if it's a custom logo stored in R2
114-
workspace.logo &&
115-
workspace.logo.startsWith(`${R2_URL}/logos/${workspace.id}`) &&
116-
storage.delete(workspace.logo.replace(`${R2_URL}/`, "")),
117-
// if they have a Stripe subscription, cancel it
118-
workspace.stripeId && cancelSubscription(workspace.stripeId),
119-
// set the referral link to `/deleted/[slug]`
120-
workspace.referralLinkId &&
121-
dub.links.update(workspace.referralLinkId, {
122-
key: `/deleted/${workspace.slug}-${workspace.id}`,
123-
archived: true,
124-
identifier: `/deleted/${workspace.slug}-${workspace.id}`,
125-
}),
126-
// delete the workspace
127-
prisma.project.delete({
128-
where: {
129-
slug: workspace.slug,
130-
},
131-
}),
132-
prisma.user.updateMany({
133-
where: {
134-
defaultWorkspace: workspace.slug,
135-
},
136-
data: {
137-
defaultWorkspace: null,
138-
},
139-
}),
140-
]);
141-
})(),
142-
);
56+
// Set the referral link to `/deleted/[slug]`
57+
workspace.referralLinkId &&
58+
dub.links.update(workspace.referralLinkId, {
59+
key: `/deleted/${workspace.slug}-${workspace.id}`,
60+
archived: true,
61+
identifier: `/deleted/${workspace.slug}-${workspace.id}`,
62+
}),
63+
]);
14364

144-
return response;
65+
await queueWorkspaceDeletion({
66+
workspaceId: workspace.id,
67+
});
14568
}
14669

14770
export async function deleteWorkspaceAdmin(
@@ -235,3 +158,19 @@ export async function deleteWorkspaceAdmin(
235158
deleteWorkspaceResponse,
236159
};
237160
}
161+
162+
export async function queueWorkspaceDeletion({
163+
workspaceId,
164+
delay,
165+
}: {
166+
workspaceId: string;
167+
delay?: number;
168+
}) {
169+
return await qstash.publishJSON({
170+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/workspaces/delete`,
171+
...(delay && { delay }),
172+
body: {
173+
workspaceId,
174+
},
175+
});
176+
}

0 commit comments

Comments
 (0)