@@ -6,11 +6,7 @@ import {
6
6
} from "@aws-sdk/client-dynamodb" ;
7
7
import { marshall , unmarshall } from "@aws-sdk/util-dynamodb" ;
8
8
import { genericConfig } from "common/config.js" ;
9
- import {
10
- BaseError ,
11
- DatabaseFetchError ,
12
- ValidationError ,
13
- } from "common/errors/index.js" ;
9
+ import { BaseError , DatabaseFetchError } from "common/errors/index.js" ;
14
10
import { OrgRole , orgRoles } from "common/roles.js" ;
15
11
import {
16
12
enforcedOrgLeadEntry ,
@@ -25,6 +21,7 @@ import { modifyGroup } from "./entraId.js";
25
21
import { EntraGroupActions } from "common/types/iam.js" ;
26
22
import { buildAuditLogTransactPut } from "./auditLog.js" ;
27
23
import { Modules } from "common/modules.js" ;
24
+ import { retryDynamoTransactionWithBackoff } from "api/utils.js" ;
28
25
29
26
export interface GetOrgInfoInputs {
30
27
id : string ;
@@ -51,6 +48,7 @@ export async function getOrgInfo({
51
48
ExpressionAttributeValues : {
52
49
":definitionId" : { S : `DEFINE#${ id } ` } ,
53
50
} ,
51
+ ConsistentRead : true ,
54
52
} ) ;
55
53
let response = { leads : [ ] } as {
56
54
leads : { name : string ; username : string ; title : string | undefined } [ ] ;
@@ -80,13 +78,14 @@ export async function getOrgInfo({
80
78
message : "Failed to get org metadata." ,
81
79
} ) ;
82
80
}
83
- // Get leads
81
+
84
82
const leadsQuery = new QueryCommand ( {
85
83
TableName : genericConfig . SigInfoTableName ,
86
84
KeyConditionExpression : "primaryKey = :leadName" ,
87
85
ExpressionAttributeValues : {
88
86
":leadName" : { S : `LEAD#${ id } ` } ,
89
87
} ,
88
+ ConsistentRead : true ,
90
89
} ) ;
91
90
try {
92
91
const responseMarshall = await dynamoClient . send ( leadsQuery ) ;
@@ -173,11 +172,6 @@ export async function getUserOrgRoles({
173
172
}
174
173
}
175
174
176
- /**
177
- * Adds a user as a lead, handling DB, Entra sync, and returning an email payload.
178
- * It will only succeed if the user is not already a lead, preventing race conditions.
179
- * @returns SQSMessage payload for email notification, or null if the user is already a lead.
180
- */
181
175
export const addLead = async ( {
182
176
user,
183
177
orgId,
@@ -203,46 +197,53 @@ export const addLead = async ({
203
197
} ) : Promise < SQSMessage | null > => {
204
198
const { username } = user ;
205
199
206
- const addTransaction = new TransactWriteItemsCommand ( {
207
- TransactItems : [
208
- buildAuditLogTransactPut ( {
209
- entry : {
210
- module : Modules . ORG_INFO ,
211
- actor : actorUsername ,
212
- target : username ,
213
- message : `Added target as a lead of ${ orgId } .` ,
214
- } ,
215
- } ) ! ,
216
- {
217
- Put : {
218
- TableName : genericConfig . SigInfoTableName ,
219
- Item : marshall ( {
220
- ...user ,
221
- primaryKey : `LEAD#${ orgId } ` ,
222
- entryId : username ,
223
- updatedAt : new Date ( ) . toISOString ( ) ,
224
- } ) ,
225
- // This condition ensures the Put operation fails if an item with this primary key already exists.
226
- ConditionExpression : "attribute_not_exists(primaryKey)" ,
200
+ const addOperation = async ( ) => {
201
+ const addTransaction = new TransactWriteItemsCommand ( {
202
+ TransactItems : [
203
+ buildAuditLogTransactPut ( {
204
+ entry : {
205
+ module : Modules . ORG_INFO ,
206
+ actor : actorUsername ,
207
+ target : username ,
208
+ message : `Added target as a lead of ${ orgId } .` ,
209
+ } ,
210
+ } ) ! ,
211
+ {
212
+ Put : {
213
+ TableName : genericConfig . SigInfoTableName ,
214
+ Item : marshall ( {
215
+ ...user ,
216
+ primaryKey : `LEAD#${ orgId } ` ,
217
+ entryId : username ,
218
+ updatedAt : new Date ( ) . toISOString ( ) ,
219
+ } ) ,
220
+ ConditionExpression :
221
+ "attribute_not_exists(primaryKey) AND attribute_not_exists(entryId)" ,
222
+ } ,
227
223
} ,
228
- } ,
229
- ] ,
230
- } ) ;
224
+ ] ,
225
+ } ) ;
226
+
227
+ return await dynamoClient . send ( addTransaction ) ;
228
+ } ;
231
229
232
230
try {
233
- await dynamoClient . send ( addTransaction ) ;
231
+ await retryDynamoTransactionWithBackoff (
232
+ addOperation ,
233
+ logger ,
234
+ `Add lead ${ username } to ${ orgId } ` ,
235
+ ) ;
234
236
} catch ( e : any ) {
235
- // This specific error is thrown when a ConditionExpression fails.
236
237
if (
237
238
e . name === "TransactionCanceledException" &&
238
239
e . message . includes ( "ConditionalCheckFailed" )
239
240
) {
240
241
logger . info (
241
242
`User ${ username } is already a lead for ${ orgId } . Skipping add operation.` ,
242
243
) ;
243
- return null ; // Gracefully exit without erroring.
244
+ return null ;
244
245
}
245
- throw e ; // Re-throw any other type of error.
246
+ throw e ;
246
247
}
247
248
248
249
logger . info (
@@ -290,11 +291,6 @@ export const addLead = async ({
290
291
} ;
291
292
} ;
292
293
293
- /**
294
- * Removes a user as a lead, handling DB, Entra sync, and returning an email payload.
295
- * It will only succeed if the user is currently a lead, and attempts to avoid race conditions in Exec group management.
296
- * @returns SQSMessage payload for email notification, or null if the user was not a lead.
297
- */
298
294
export const removeLead = async ( {
299
295
username,
300
296
orgId,
@@ -318,44 +314,41 @@ export const removeLead = async ({
318
314
execGroupId : string ;
319
315
officersEmail : string ;
320
316
} ) : Promise < SQSMessage | null > => {
321
- const getDelayed = async ( ) => {
322
- // HACK: wait up to 30ms in an attempt to de-sync the threads on checking leads.
323
- // Yes, I know this is bad. But because of a lack of consistent reads on Dynamo GSIs,
324
- // we're going to have to run with it for now.
325
- const sleepMs = Math . random ( ) * 30 ;
326
- logger . info ( `Sleeping for ${ sleepMs } ms before checking.` ) ;
327
- await new Promise ( ( resolve ) => setTimeout ( resolve , sleepMs ) ) ;
328
- return getUserOrgRoles ( { username, dynamoClient, logger } ) ;
329
- } ;
330
- const userRolesPromise = getDelayed ( ) ;
331
- const removeTransaction = new TransactWriteItemsCommand ( {
332
- TransactItems : [
333
- buildAuditLogTransactPut ( {
334
- entry : {
335
- module : Modules . ORG_INFO ,
336
- actor : actorUsername ,
337
- target : username ,
338
- message : `Removed target from lead of ${ orgId } .` ,
339
- } ,
340
- } ) ! ,
341
- {
342
- Delete : {
343
- TableName : genericConfig . SigInfoTableName ,
344
- Key : marshall ( {
345
- primaryKey : `LEAD#${ orgId } ` ,
346
- entryId : username ,
347
- } ) ,
348
- // Idempotent
349
- ConditionExpression : "attribute_exists(primaryKey)" ,
317
+ const removeOperation = async ( ) => {
318
+ const removeTransaction = new TransactWriteItemsCommand ( {
319
+ TransactItems : [
320
+ buildAuditLogTransactPut ( {
321
+ entry : {
322
+ module : Modules . ORG_INFO ,
323
+ actor : actorUsername ,
324
+ target : username ,
325
+ message : `Removed target from lead of ${ orgId } .` ,
326
+ } ,
327
+ } ) ! ,
328
+ {
329
+ Delete : {
330
+ TableName : genericConfig . SigInfoTableName ,
331
+ Key : marshall ( {
332
+ primaryKey : `LEAD#${ orgId } ` ,
333
+ entryId : username ,
334
+ } ) ,
335
+ ConditionExpression :
336
+ "attribute_exists(primaryKey) AND attribute_exists(entryId)" ,
337
+ } ,
350
338
} ,
351
- } ,
352
- ] ,
353
- } ) ;
339
+ ] ,
340
+ } ) ;
341
+
342
+ return await dynamoClient . send ( removeTransaction ) ;
343
+ } ;
354
344
355
345
try {
356
- await dynamoClient . send ( removeTransaction ) ;
346
+ await retryDynamoTransactionWithBackoff (
347
+ removeOperation ,
348
+ logger ,
349
+ `Remove lead ${ username } from ${ orgId } ` ,
350
+ ) ;
357
351
} catch ( e : any ) {
358
- // This specific error is thrown when a ConditionExpression fails, meaning we do nothing.
359
352
if (
360
353
e . name === "TransactionCanceledException" &&
361
354
e . message . includes ( "ConditionalCheckFailed" )
@@ -385,11 +378,12 @@ export const removeLead = async ({
385
378
) ;
386
379
}
387
380
388
- const userRoles = await userRolesPromise ;
389
- // Since the read is eventually consistent, don't count the role we just removed if it still gets returned.
381
+ // Use consistent read to check if user has other lead roles
382
+ const userRoles = await getUserOrgRoles ( { username , dynamoClient , logger } ) ;
390
383
const otherLeadRoles = userRoles
391
384
. filter ( ( x ) => x . role === "LEAD" )
392
385
. filter ( ( x ) => x . org !== orgId ) ;
386
+
393
387
if ( otherLeadRoles . length === 0 ) {
394
388
await modifyGroup (
395
389
entraIdToken ,
0 commit comments