Skip to content

Commit d1aa094

Browse files
feat: bug delete user (#2196)
* feat: bug delete user * Update packages/services/src/user/delete.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent eff5c7a commit d1aa094

1 file changed

Lines changed: 82 additions & 15 deletions

File tree

packages/services/src/user/delete.ts

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { and, eq, ne } from "@openstatus/db";
1+
import { and, count, eq, isNull, ne } from "@openstatus/db";
22
import {
33
account,
4+
monitor,
5+
notification,
6+
page,
7+
selectWorkspaceSchema,
48
session,
59
user,
610
usersToWorkspaces,
@@ -18,29 +22,27 @@ import {
1822
PreconditionFailedError,
1923
UnauthorizedError,
2024
} from "../errors";
25+
import { deleteMonitors } from "../monitor/delete";
26+
import { deleteNotification } from "../notification/delete";
27+
import { deletePage } from "../page/delete";
2128
import { DeleteAccountInput } from "./schemas";
2229

2330
/**
2431
* Soft-delete a user account:
2532
* 1. Refuses to proceed if the user owns a workspace on a paid plan — they
2633
* must cancel the subscription first. (Legacy behavior; preserves the
2734
* revenue guardrail.)
28-
* 2. Removes their membership from every workspace they don't own.
29-
* 3. Deletes their sessions and OAuth accounts.
30-
* 4. Blanks out PII on the user row and stamps `deletedAt`.
35+
* 2. For every owned workspace where this user is the only remaining
36+
* member, soft-deletes the monitors and deletes the pages and
37+
* notifications so we don't keep running probes / sending alerts for
38+
* an unreachable owner. The workspace row and the owner membership
39+
* are intentionally left in place; reclaiming those is out of scope.
40+
* 3. Removes their membership from every workspace they don't own.
41+
* 4. Deletes their sessions and OAuth accounts.
42+
* 5. Blanks out PII on the user row and stamps `deletedAt`.
3143
*
32-
* All four writes run in a single transaction so a partial failure never
44+
* All writes run in a single transaction so a partial failure never
3345
* leaves the account half-deleted.
34-
*
35-
* **Scope note — owned workspaces are not cleaned up here.** The user's
36-
* `usersToWorkspaces` rows where `role === "owner"` survive, along with
37-
* every workspace / monitor / page they own. Since only free-plan users
38-
* reach this path (the paid-plan guard above), the outcome is an
39-
* orphaned free workspace with no active owner, which matches the
40-
* legacy router behavior. Workspace-level cleanup (reclaim slots, tombstone
41-
* unowned free workspaces) is explicitly out of scope for this service —
42-
* if/when it lands, it'll be a separate admin / scheduled job rather
43-
* than inline here.
4446
*/
4547
export async function deleteAccount(args: {
4648
ctx: ServiceContext;
@@ -95,6 +97,71 @@ export async function deleteAccount(args: {
9597
);
9698
}
9799

100+
for (const { workspace: rawWorkspace } of ownedRows) {
101+
const others = await tx
102+
.select({ c: count() })
103+
.from(usersToWorkspaces)
104+
.innerJoin(user, eq(user.id, usersToWorkspaces.userId))
105+
.where(
106+
and(
107+
eq(usersToWorkspaces.workspaceId, rawWorkspace.id),
108+
ne(usersToWorkspaces.userId, userId),
109+
isNull(user.deletedAt),
110+
),
111+
)
112+
.get();
113+
if ((others?.c ?? 0) > 0) continue;
114+
115+
// Sub-context targets the orphaned workspace so audit rows
116+
// land under the right `workspace_id` and the per-entity
117+
// services pass their `workspaceId` scoping checks. The same
118+
// `tx` is threaded through so everything still rolls back as
119+
// one unit if any cleanup step fails.
120+
const subCtx: ServiceContext = {
121+
...ctx,
122+
workspace: selectWorkspaceSchema.parse(rawWorkspace),
123+
db: tx,
124+
};
125+
126+
const monitorIds = (
127+
await tx
128+
.select({ id: monitor.id })
129+
.from(monitor)
130+
.where(
131+
and(
132+
eq(monitor.workspaceId, rawWorkspace.id),
133+
isNull(monitor.deletedAt),
134+
),
135+
)
136+
.all()
137+
).map((m) => m.id);
138+
if (monitorIds.length > 0) {
139+
await deleteMonitors({ ctx: subCtx, input: { ids: monitorIds } });
140+
}
141+
142+
const pageIds = (
143+
await tx
144+
.select({ id: page.id })
145+
.from(page)
146+
.where(eq(page.workspaceId, rawWorkspace.id))
147+
.all()
148+
).map((p) => p.id);
149+
for (const id of pageIds) {
150+
await deletePage({ ctx: subCtx, input: { id } });
151+
}
152+
153+
const notificationIds = (
154+
await tx
155+
.select({ id: notification.id })
156+
.from(notification)
157+
.where(eq(notification.workspaceId, rawWorkspace.id))
158+
.all()
159+
).map((n) => n.id);
160+
for (const id of notificationIds) {
161+
await deleteNotification({ ctx: subCtx, input: { id } });
162+
}
163+
}
164+
98165
await tx
99166
.delete(usersToWorkspaces)
100167
.where(

0 commit comments

Comments
 (0)