@@ -6,11 +6,7 @@ import {
66} from "@aws-sdk/client-dynamodb" ;
77import { marshall , unmarshall } from "@aws-sdk/util-dynamodb" ;
88import { 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" ;
1410import { OrgRole , orgRoles } from "common/roles.js" ;
1511import {
1612 enforcedOrgLeadEntry ,
@@ -25,6 +21,7 @@ import { modifyGroup } from "./entraId.js";
2521import { EntraGroupActions } from "common/types/iam.js" ;
2622import { buildAuditLogTransactPut } from "./auditLog.js" ;
2723import { Modules } from "common/modules.js" ;
24+ import { retryDynamoTransactionWithBackoff } from "api/utils.js" ;
2825
2926export interface GetOrgInfoInputs {
3027 id : string ;
@@ -51,6 +48,7 @@ export async function getOrgInfo({
5148 ExpressionAttributeValues : {
5249 ":definitionId" : { S : `DEFINE#${ id } ` } ,
5350 } ,
51+ ConsistentRead : true ,
5452 } ) ;
5553 let response = { leads : [ ] } as {
5654 leads : { name : string ; username : string ; title : string | undefined } [ ] ;
@@ -80,13 +78,14 @@ export async function getOrgInfo({
8078 message : "Failed to get org metadata." ,
8179 } ) ;
8280 }
83- // Get leads
81+
8482 const leadsQuery = new QueryCommand ( {
8583 TableName : genericConfig . SigInfoTableName ,
8684 KeyConditionExpression : "primaryKey = :leadName" ,
8785 ExpressionAttributeValues : {
8886 ":leadName" : { S : `LEAD#${ id } ` } ,
8987 } ,
88+ ConsistentRead : true ,
9089 } ) ;
9190 try {
9291 const responseMarshall = await dynamoClient . send ( leadsQuery ) ;
@@ -173,11 +172,6 @@ export async function getUserOrgRoles({
173172 }
174173}
175174
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- */
181175export const addLead = async ( {
182176 user,
183177 orgId,
@@ -203,46 +197,53 @@ export const addLead = async ({
203197} ) : Promise < SQSMessage | null > => {
204198 const { username } = user ;
205199
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+ } ,
227223 } ,
228- } ,
229- ] ,
230- } ) ;
224+ ] ,
225+ } ) ;
226+
227+ return await dynamoClient . send ( addTransaction ) ;
228+ } ;
231229
232230 try {
233- await dynamoClient . send ( addTransaction ) ;
231+ await retryDynamoTransactionWithBackoff (
232+ addOperation ,
233+ logger ,
234+ `Add lead ${ username } to ${ orgId } ` ,
235+ ) ;
234236 } catch ( e : any ) {
235- // This specific error is thrown when a ConditionExpression fails.
236237 if (
237238 e . name === "TransactionCanceledException" &&
238239 e . message . includes ( "ConditionalCheckFailed" )
239240 ) {
240241 logger . info (
241242 `User ${ username } is already a lead for ${ orgId } . Skipping add operation.` ,
242243 ) ;
243- return null ; // Gracefully exit without erroring.
244+ return null ;
244245 }
245- throw e ; // Re-throw any other type of error.
246+ throw e ;
246247 }
247248
248249 logger . info (
@@ -290,11 +291,6 @@ export const addLead = async ({
290291 } ;
291292} ;
292293
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- */
298294export const removeLead = async ( {
299295 username,
300296 orgId,
@@ -318,44 +314,41 @@ export const removeLead = async ({
318314 execGroupId : string ;
319315 officersEmail : string ;
320316} ) : 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+ } ,
350338 } ,
351- } ,
352- ] ,
353- } ) ;
339+ ] ,
340+ } ) ;
341+
342+ return await dynamoClient . send ( removeTransaction ) ;
343+ } ;
354344
355345 try {
356- await dynamoClient . send ( removeTransaction ) ;
346+ await retryDynamoTransactionWithBackoff (
347+ removeOperation ,
348+ logger ,
349+ `Remove lead ${ username } from ${ orgId } ` ,
350+ ) ;
357351 } catch ( e : any ) {
358- // This specific error is thrown when a ConditionExpression fails, meaning we do nothing.
359352 if (
360353 e . name === "TransactionCanceledException" &&
361354 e . message . includes ( "ConditionalCheckFailed" )
@@ -385,11 +378,12 @@ export const removeLead = async ({
385378 ) ;
386379 }
387380
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 } ) ;
390383 const otherLeadRoles = userRoles
391384 . filter ( ( x ) => x . role === "LEAD" )
392385 . filter ( ( x ) => x . org !== orgId ) ;
386+
393387 if ( otherLeadRoles . length === 0 ) {
394388 await modifyGroup (
395389 entraIdToken ,
0 commit comments