1- import { and , eq , ne } from "@openstatus/db" ;
1+ import { and , count , eq , isNull , ne } from "@openstatus/db" ;
22import {
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" ;
2128import { 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 */
4547export 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