diff --git a/packages/services/src/user/delete.ts b/packages/services/src/user/delete.ts index 5d4d2b7d..e5ee86dc 100644 --- a/packages/services/src/user/delete.ts +++ b/packages/services/src/user/delete.ts @@ -1,6 +1,10 @@ -import { and, eq, ne } from "@openstatus/db"; +import { and, count, eq, isNull, ne } from "@openstatus/db"; import { account, + monitor, + notification, + page, + selectWorkspaceSchema, session, user, usersToWorkspaces, @@ -18,6 +22,9 @@ import { PreconditionFailedError, UnauthorizedError, } from "../errors"; +import { deleteMonitors } from "../monitor/delete"; +import { deleteNotification } from "../notification/delete"; +import { deletePage } from "../page/delete"; import { DeleteAccountInput } from "./schemas"; /** @@ -25,22 +32,17 @@ import { DeleteAccountInput } from "./schemas"; * 1. Refuses to proceed if the user owns a workspace on a paid plan — they * must cancel the subscription first. (Legacy behavior; preserves the * revenue guardrail.) - * 2. Removes their membership from every workspace they don't own. - * 3. Deletes their sessions and OAuth accounts. - * 4. Blanks out PII on the user row and stamps `deletedAt`. + * 2. For every owned workspace where this user is the only remaining + * member, soft-deletes the monitors and deletes the pages and + * notifications so we don't keep running probes / sending alerts for + * an unreachable owner. The workspace row and the owner membership + * are intentionally left in place; reclaiming those is out of scope. + * 3. Removes their membership from every workspace they don't own. + * 4. Deletes their sessions and OAuth accounts. + * 5. Blanks out PII on the user row and stamps `deletedAt`. * - * All four writes run in a single transaction so a partial failure never + * All writes run in a single transaction so a partial failure never * leaves the account half-deleted. - * - * **Scope note — owned workspaces are not cleaned up here.** The user's - * `usersToWorkspaces` rows where `role === "owner"` survive, along with - * every workspace / monitor / page they own. Since only free-plan users - * reach this path (the paid-plan guard above), the outcome is an - * orphaned free workspace with no active owner, which matches the - * legacy router behavior. Workspace-level cleanup (reclaim slots, tombstone - * unowned free workspaces) is explicitly out of scope for this service — - * if/when it lands, it'll be a separate admin / scheduled job rather - * than inline here. */ export async function deleteAccount(args: { ctx: ServiceContext; @@ -95,6 +97,71 @@ export async function deleteAccount(args: { ); } + for (const { workspace: rawWorkspace } of ownedRows) { + const others = await tx + .select({ c: count() }) + .from(usersToWorkspaces) + .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) + .where( + and( + eq(usersToWorkspaces.workspaceId, rawWorkspace.id), + ne(usersToWorkspaces.userId, userId), + isNull(user.deletedAt), + ), + ) + .get(); + if ((others?.c ?? 0) > 0) continue; + + // Sub-context targets the orphaned workspace so audit rows + // land under the right `workspace_id` and the per-entity + // services pass their `workspaceId` scoping checks. The same + // `tx` is threaded through so everything still rolls back as + // one unit if any cleanup step fails. + const subCtx: ServiceContext = { + ...ctx, + workspace: selectWorkspaceSchema.parse(rawWorkspace), + db: tx, + }; + + const monitorIds = ( + await tx + .select({ id: monitor.id }) + .from(monitor) + .where( + and( + eq(monitor.workspaceId, rawWorkspace.id), + isNull(monitor.deletedAt), + ), + ) + .all() + ).map((m) => m.id); + if (monitorIds.length > 0) { + await deleteMonitors({ ctx: subCtx, input: { ids: monitorIds } }); + } + + const pageIds = ( + await tx + .select({ id: page.id }) + .from(page) + .where(eq(page.workspaceId, rawWorkspace.id)) + .all() + ).map((p) => p.id); + for (const id of pageIds) { + await deletePage({ ctx: subCtx, input: { id } }); + } + + const notificationIds = ( + await tx + .select({ id: notification.id }) + .from(notification) + .where(eq(notification.workspaceId, rawWorkspace.id)) + .all() + ).map((n) => n.id); + for (const id of notificationIds) { + await deleteNotification({ ctx: subCtx, input: { id } }); + } + } + await tx .delete(usersToWorkspaces) .where(