Skip to content

Commit 13c1cd4

Browse files
committed
fix(sim-links): repair leftover links on account deletion
1 parent 463be58 commit 13c1cd4

3 files changed

Lines changed: 83 additions & 5 deletions

File tree

‎apps/sim/lib/billing/organizations/resource-handoff.ts‎

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
user,
88
workspaceFiles,
99
} from '@sim/db/schema'
10-
import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm'
10+
import { and, asc, eq, isNull, ne, notInArray, sql } from 'drizzle-orm'
1111
import { OrchestrationError } from '@/lib/core/orchestration/types'
1212
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
1313
import { handoffFileCreatorsInTx } from '@/lib/uploads/contexts/workspace/creator-handoff'
@@ -104,10 +104,7 @@ export async function reassignOrganizationSharedResourcesTx(
104104
eq(permissionGroup.createdBy, departingUserId)
105105
)
106106
)
107-
await tx
108-
.update(simLink)
109-
.set({ createdBy: userId, updatedAt })
110-
.where(and(eq(simLink.organizationId, organizationId), eq(simLink.createdBy, departingUserId)))
107+
await reassignSimLinksTx(tx, organizationId, departingUserId, userId)
111108
await tx
112109
.update(ssoProvider)
113110
.set({ userId })
@@ -116,3 +113,48 @@ export async function reassignOrganizationSharedResourcesTx(
116113
)
117114
return userId
118115
}
116+
117+
async function reassignSimLinksTx(
118+
tx: DbTransaction,
119+
organizationId: string,
120+
fromUserId: string,
121+
toUserId: string
122+
) {
123+
await tx
124+
.update(simLink)
125+
.set({ createdBy: toUserId, updatedAt: new Date() })
126+
.where(and(eq(simLink.organizationId, organizationId), eq(simLink.createdBy, fromUserId)))
127+
}
128+
129+
/**
130+
* Repairs Sim links a former member kept after leaving before departures handed them off. Never
131+
* blocks erasure: an organization without an owner successor keeps the FK's `SET NULL`. Takes
132+
* only the successor's row locks, so it is safe after the deleting account's user barrier.
133+
*/
134+
export async function handoffLeftoverSimLinksTx(
135+
tx: DbTransaction,
136+
departingUserId: string,
137+
handledOrganizationIds: string[]
138+
) {
139+
const rows = await tx
140+
.selectDistinct({ organizationId: simLink.organizationId })
141+
.from(simLink)
142+
.where(
143+
and(
144+
eq(simLink.createdBy, departingUserId),
145+
handledOrganizationIds.length > 0
146+
? notInArray(simLink.organizationId, handledOrganizationIds)
147+
: undefined
148+
)
149+
)
150+
.orderBy(asc(simLink.organizationId))
151+
for (const { organizationId } of rows) {
152+
const successorId = await findOrganizationSharedResourceSuccessor(
153+
tx,
154+
organizationId,
155+
departingUserId,
156+
true
157+
)
158+
if (successorId) await reassignSimLinksTx(tx, organizationId, departingUserId, successorId)
159+
}
160+
}

‎apps/sim/lib/users/account-deletion.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
} from '@/lib/billing/organizations/membership'
3535
import {
3636
findOrganizationSharedResourceSuccessor,
37+
handoffLeftoverSimLinksTx,
3738
listSharedResourceOrganizationIdsForUser,
3839
reassignOrganizationSharedResourcesTx,
3940
} from '@/lib/billing/organizations/resource-handoff'
@@ -965,6 +966,7 @@ export async function deleteUserAccount(
965966
for (const organizationId of sharedOrganizationIds) {
966967
await reassignOrganizationSharedResourcesTx(tx, organizationId, userId)
967968
}
969+
await handoffLeftoverSimLinksTx(tx, userId, sharedOrganizationIds)
968970

969971
const sharedWorkspaceIds = await listSharedResourceWorkspaceIdsForUser(tx, userId)
970972
const { unresolved: resourceUnresolved } =

‎apps/sim/lib/workspaces/access/shared-resource-handoff.integration.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,40 @@ describe('shared resource retention across workspace departure and account erasu
13341334
)
13351335
})
13361336

1337+
it("repairs a former member's leftover Sim link to the owner on account deletion", async () => {
1338+
const fixture = await seedResources(false, false)
1339+
const organizationId = generateId()
1340+
const linkId = generateId()
1341+
organizationIds.push(organizationId)
1342+
await db.insert(organization).values({
1343+
id: organizationId,
1344+
name: 'Former organization',
1345+
slug: organizationId,
1346+
createdAt: new Date(),
1347+
})
1348+
await db.insert(member).values({
1349+
id: generateId(),
1350+
organizationId,
1351+
userId: fixture.ownerId,
1352+
role: 'owner',
1353+
createdAt: new Date(),
1354+
})
1355+
await db.insert(simLink).values({
1356+
id: linkId,
1357+
organizationId,
1358+
name: 'handbook',
1359+
normalizedName: 'handbook',
1360+
url: 'https://example.test/handbook',
1361+
restrictEdit: true,
1362+
createdBy: fixture.departingId,
1363+
})
1364+
await deleteUserAccount(fixture.departingId, { recordIdentity: false })
1365+
expect(
1366+
await db.select({ createdBy: simLink.createdBy }).from(simLink).where(eq(simLink.id, linkId))
1367+
).toEqual([{ createdBy: fixture.ownerId }])
1368+
await assertRetained(fixture)
1369+
})
1370+
13371371
it('refuses organization-only retention without an owner successor', async () => {
13381372
const fixture = await seedResources(false, false)
13391373
const organizationId = generateId()

0 commit comments

Comments
 (0)