@@ -5,6 +5,8 @@ import { normalizeEmail } from '@sim/utils/string'
55import type { ScimPatchOperation } from '@/lib/api/contracts/scim'
66import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
77import type { DbOrTx , DbTransaction } from '@/lib/db/types'
8+ import type { StoppedRun } from '@/lib/mothership/async-runs/repository'
9+ import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery'
810import { suspendMemberTx , unsuspendMemberTx } from '@/lib/organizations/members/lifecycle'
911import {
1012 invalidateAfterSessionRevocation ,
@@ -42,18 +44,24 @@ import {
4244 * deactivation actually does.
4345 */
4446
45- export interface UpdateOutcome {
47+ interface UpdateOutcome {
4648 emailChanged : boolean
4749 deactivated : boolean
4850 reactivated : boolean
4951}
5052
53+ /** An applied update and the runs its projection stopped, delivered once the write commits. */
54+ interface AppliedUserUpdate {
55+ outcome : UpdateOutcome
56+ stoppedRuns : StoppedRun [ ]
57+ }
58+
5159async function applyUserUpdate (
5260 tx : DbTransaction ,
5361 context : ScimUseCaseContext ,
5462 current : ScimUserRecord ,
5563 next : ScimUserAttributes
56- ) : Promise < UpdateOutcome > {
64+ ) : Promise < AppliedUserUpdate > {
5765 const nextEmail = primaryEmail ( next )
5866 const nextName = accountName ( next )
5967 const emailChanged = accountEmailDiverged ( current , next )
@@ -108,14 +116,17 @@ async function applyUserUpdate(
108116 active : next . active ,
109117 } )
110118
111- await reconcileUserProjection ( tx , {
119+ const projection = await reconcileUserProjection ( tx , {
112120 connectionId : context . connection . id ,
113121 organizationId : context . organizationId ,
114122 scimUserId : current . id ,
115123 settings : context . connection . settings ,
116124 } )
117125
118- return { emailChanged, deactivated, reactivated }
126+ return {
127+ outcome : { emailChanged, deactivated, reactivated } ,
128+ stoppedRuns : projection . stoppedRuns ,
129+ }
119130}
120131
121132export interface UpdateScimUserResult {
@@ -125,6 +136,27 @@ export interface UpdateScimUserResult {
125136 resource : ReturnType < typeof toUserResource >
126137}
127138
139+ /** Runs a user write in one transaction and delivers the runs it stopped once that commits. */
140+ async function writeUser (
141+ context : ScimUseCaseContext ,
142+ scimUserId : string ,
143+ update : ( tx : DbTransaction , current : ScimUserRecord ) => Promise < AppliedUserUpdate | null >
144+ ) : Promise < UpdateScimUserResult > {
145+ const { stoppedRuns, ...result } = await db . transaction ( async ( tx ) => {
146+ const current = await loadUserForUpdate ( tx , context , scimUserId )
147+ const applied = await update ( tx , current )
148+ return {
149+ scimUserId : current . id ,
150+ userId : current . userId ,
151+ outcome : applied ?. outcome ?? null ,
152+ resource : await renderUpdated ( tx , context . connection . id , current . id , context . baseUrl ) ,
153+ stoppedRuns : applied ?. stoppedRuns ?? [ ] ,
154+ }
155+ } )
156+ deliverRunStops ( stoppedRuns )
157+ return result
158+ }
159+
128160/**
129161 * Loads the stored resource under the organization and user advisory locks.
130162 *
@@ -229,9 +261,7 @@ export const replaceScimUser = defineAuthorizedScimUseCase({
229261 input,
230262 context,
231263 } : ScimUseCaseArgs < ReplaceScimUserInput > ) : Promise < UpdateScimUserResult > {
232- return db . transaction ( async ( tx ) => {
233- const current = await loadUserForUpdate ( tx , context , input . scimUserId )
234-
264+ return writeUser ( context , input . scimUserId , async ( tx , current ) => {
235265 /**
236266 * A replace keeps attributes Sim does not model that the directory sent on
237267 * a previous write but omitted now, so a partial mapping does not erase
@@ -249,16 +279,10 @@ export const replaceScimUser = defineAuthorizedScimUseCase({
249279 * changes nothing must not write, audit, or re-project, or a 2,000-user
250280 * organization produces 2,000 spurious audit rows per sync.
251281 */
252- const outcome =
253- userAttributesEqual ( current . attributes , next ) && ! accountIdentityDiverged ( current , next )
254- ? null
255- : await applyUserUpdate ( tx , context , current , next )
256- return {
257- scimUserId : current . id ,
258- userId : current . userId ,
259- outcome,
260- resource : await renderUpdated ( tx , context . connection . id , current . id , context . baseUrl ) ,
261- }
282+ return userAttributesEqual ( current . attributes , next ) &&
283+ ! accountIdentityDiverged ( current , next )
284+ ? null
285+ : applyUserUpdate ( tx , context , current , next )
262286 } )
263287 } ,
264288 projectAudit : ( { result } ) => auditEntries ( result ) ,
@@ -277,9 +301,7 @@ export const patchScimUser = defineAuthorizedScimUseCase({
277301 input,
278302 context,
279303 } : ScimUseCaseArgs < PatchScimUserInput > ) : Promise < UpdateScimUserResult > {
280- return db . transaction ( async ( tx ) => {
281- const current = await loadUserForUpdate ( tx , context , input . scimUserId )
282-
304+ return writeUser ( context , input . scimUserId , async ( tx , current ) => {
283305 const { next, changed } = applyUserPatch ( current . attributes , input . operations )
284306
285307 /**
@@ -289,16 +311,9 @@ export const patchScimUser = defineAuthorizedScimUseCase({
289311 * projection pass, and a `lastModified` bump for a request that meant
290312 * nothing.
291313 */
292- const outcome =
293- changed || accountIdentityDiverged ( current , next )
294- ? await applyUserUpdate ( tx , context , current , next )
295- : null
296- return {
297- scimUserId : current . id ,
298- userId : current . userId ,
299- outcome,
300- resource : await renderUpdated ( tx , context . connection . id , current . id , context . baseUrl ) ,
301- }
314+ return changed || accountIdentityDiverged ( current , next )
315+ ? applyUserUpdate ( tx , context , current , next )
316+ : null
302317 } )
303318 } ,
304319 projectAudit : ( { result } ) => auditEntries ( result ) ,
0 commit comments