1- import { createHash } from 'crypto'
21import { db } from '@sim/db'
32import {
43 type CredentialGroupOptionConfig ,
87 user ,
98 workspace ,
109} from '@sim/db/schema'
10+ import { sha256Hex } from '@sim/security/hash'
1111import { getErrorMessage } from '@sim/utils/errors'
1212import { generateId } from '@sim/utils/id'
1313import { normalizeEmail , truncate } from '@sim/utils/string'
@@ -55,6 +55,11 @@ interface InvitationContext {
5555 groupName : string
5656}
5757
58+ interface SendInvitationOptions {
59+ expectedEnrollmentId ?: string
60+ revokedEnrollment : 'reactivate' | 'reject'
61+ }
62+
5863export interface PublicCredentialGroupEnrollment {
5964 inviterName : string
6065 workspaceName : string
@@ -97,6 +102,19 @@ export async function lockCredentialGroupEnrollmentLifecycle(
97102 )
98103}
99104
105+ /** Serializes invitation issuance before an enrollment row is known or locked. */
106+ async function lockCredentialGroupInvitationTarget (
107+ executor : DbOrTx ,
108+ groupId : string ,
109+ email : string
110+ ) : Promise < void > {
111+ if ( ! groupId . trim ( ) ) throw new Error ( 'Credential group ID is required' )
112+ if ( ! email . trim ( ) ) throw new Error ( 'Credential group enrollment email is required' )
113+ await executor . execute (
114+ sql `SELECT pg_advisory_xact_lock(hashtextextended(${ `credential-group-invitation:${ groupId } :${ email } ` } , 0))`
115+ )
116+ }
117+
100118export class CredentialGroupEnrollmentError extends Error {
101119 constructor (
102120 message : string ,
@@ -108,7 +126,7 @@ export class CredentialGroupEnrollmentError extends Error {
108126}
109127
110128function hashInvitationToken ( token : string ) : string {
111- return createHash ( 'sha256' ) . update ( token ) . digest ( 'hex' )
129+ return sha256Hex ( token )
112130}
113131
114132function metadataString ( metadata : object | null , key : string ) : string | null {
@@ -209,20 +227,53 @@ async function sendInvitation(
209227 context : InvitationContext ,
210228 userId : string ,
211229 inviterName : string ,
212- email : string
230+ email : string ,
231+ options : SendInvitationOptions
213232) : Promise < CredentialGroupEnrollment > {
214233 const now = new Date ( )
215234 const token = generateId ( )
216235 const tokenHash = hashInvitationToken ( token )
217236 const expiresAt = new Date ( now . getTime ( ) + INVITATION_TTL_MS )
218237
219- const [ issued ] = await db
220- . insert ( credentialGroupEnrollment )
221- . values ( {
222- id : generateId ( ) ,
223- credentialGroupId : context . groupId ,
224- email,
225- status : 'invited' ,
238+ const issued = await db . transaction ( async ( tx ) => {
239+ await lockCredentialGroupInvitationTarget ( tx , context . groupId , email )
240+ const [ existing ] = await tx
241+ . select ( )
242+ . from ( credentialGroupEnrollment )
243+ . where (
244+ and (
245+ eq ( credentialGroupEnrollment . credentialGroupId , context . groupId ) ,
246+ eq ( credentialGroupEnrollment . email , email )
247+ )
248+ )
249+ . limit ( 1 )
250+
251+ let current = existing
252+ if ( existing ) {
253+ await lockCredentialGroupEnrollmentLifecycle ( tx , existing . id )
254+ const [ locked ] = await tx
255+ . select ( )
256+ . from ( credentialGroupEnrollment )
257+ . where (
258+ and (
259+ eq ( credentialGroupEnrollment . id , existing . id ) ,
260+ eq ( credentialGroupEnrollment . credentialGroupId , context . groupId ) ,
261+ eq ( credentialGroupEnrollment . email , email )
262+ )
263+ )
264+ . limit ( 1 )
265+ current = locked
266+ }
267+
268+ if ( options . expectedEnrollmentId && current ?. id !== options . expectedEnrollmentId ) {
269+ throw new CredentialGroupEnrollmentError ( 'Enrollment not found' , 404 )
270+ }
271+ if ( current ?. status === 'revoked' && options . revokedEnrollment === 'reject' ) {
272+ throw new CredentialGroupEnrollmentError ( 'Revoked enrollment cannot be resent' , 409 )
273+ }
274+
275+ const mutableValues = {
276+ status : 'invited' as const ,
226277 invitationTokenHash : tokenHash ,
227278 invitationExpiresAt : expiresAt ,
228279 invitedAt : now ,
@@ -231,26 +282,27 @@ async function sendInvitation(
231282 revokedAt : null ,
232283 lastDeliveryError : null ,
233284 createdBy : userId ,
234- createdAt : now ,
235285 updatedAt : now ,
236- } )
237- . onConflictDoUpdate ( {
238- target : [ credentialGroupEnrollment . credentialGroupId , credentialGroupEnrollment . email ] ,
239- set : {
240- status : 'invited' ,
241- invitationTokenHash : tokenHash ,
242- invitationExpiresAt : expiresAt ,
243- invitedAt : now ,
244- sentAt : null ,
245- completedAt : null ,
246- revokedAt : null ,
247- lastDeliveryError : null ,
248- createdBy : userId ,
249- updatedAt : now ,
250- } ,
251- } )
252- . returning ( )
253- if ( ! issued ) throw new Error ( 'Credential group enrollment upsert returned no row' )
286+ }
287+ const [ next ] = current
288+ ? await tx
289+ . update ( credentialGroupEnrollment )
290+ . set ( mutableValues )
291+ . where ( eq ( credentialGroupEnrollment . id , current . id ) )
292+ . returning ( )
293+ : await tx
294+ . insert ( credentialGroupEnrollment )
295+ . values ( {
296+ id : generateId ( ) ,
297+ credentialGroupId : context . groupId ,
298+ email,
299+ ...mutableValues ,
300+ createdAt : now ,
301+ } )
302+ . returning ( )
303+ if ( ! next ) throw new Error ( 'Credential group enrollment write returned no row' )
304+ return next
305+ } )
254306
255307 const invitationLink = `${ getBaseUrl ( ) } /credential-groups/enroll/${ token } `
256308 const html = await renderCredentialGroupInvitationEmail ( {
@@ -269,7 +321,7 @@ async function sendInvitation(
269321 } )
270322
271323 if ( ! result . success ) {
272- await db
324+ const [ failed ] = await db
273325 . update ( credentialGroupEnrollment )
274326 . set ( {
275327 status : 'delivery_failed' ,
@@ -279,9 +331,17 @@ async function sendInvitation(
279331 . where (
280332 and (
281333 eq ( credentialGroupEnrollment . id , issued . id ) ,
282- eq ( credentialGroupEnrollment . invitationTokenHash , tokenHash )
334+ eq ( credentialGroupEnrollment . invitationTokenHash , tokenHash ) ,
335+ eq ( credentialGroupEnrollment . status , 'invited' )
283336 )
284337 )
338+ . returning ( { id : credentialGroupEnrollment . id } )
339+ if ( ! failed ) {
340+ throw new CredentialGroupEnrollmentError (
341+ 'Invitation was superseded by another enrollment action' ,
342+ 409
343+ )
344+ }
285345 throw new CredentialGroupEnrollmentError ( result . message , 502 )
286346 }
287347
@@ -291,7 +351,8 @@ async function sendInvitation(
291351 . where (
292352 and (
293353 eq ( credentialGroupEnrollment . id , issued . id ) ,
294- eq ( credentialGroupEnrollment . invitationTokenHash , tokenHash )
354+ eq ( credentialGroupEnrollment . invitationTokenHash , tokenHash ) ,
355+ eq ( credentialGroupEnrollment . status , 'invited' )
295356 )
296357 )
297358 . returning ( )
@@ -449,7 +510,9 @@ export async function inviteCredentialGroupEnrollments(
449510 const chunkResults = await Promise . all (
450511 chunk . map ( async ( email ) => {
451512 try {
452- const enrollment = await sendInvitation ( context , userId , inviterName , email )
513+ const enrollment = await sendInvitation ( context , userId , inviterName , email , {
514+ revokedEnrollment : 'reactivate' ,
515+ } )
453516 return { email, success : true as const , enrollment }
454517 } catch ( error ) {
455518 return {
@@ -486,7 +549,9 @@ export async function inviteCredentialGroupEnrollment(
486549 email : string
487550) : Promise < CredentialGroupEnrollment > {
488551 const context = await getInvitationContext ( workspaceId , groupId )
489- return sendInvitation ( context , userId , inviterName , normalizeEmail ( email ) )
552+ return sendInvitation ( context , userId , inviterName , normalizeEmail ( email ) , {
553+ revokedEnrollment : 'reactivate' ,
554+ } )
490555}
491556
492557export async function resendCredentialGroupEnrollment (
@@ -510,10 +575,10 @@ export async function resendCredentialGroupEnrollment(
510575 )
511576 . limit ( 1 )
512577 if ( ! row ) throw new CredentialGroupEnrollmentError ( 'Enrollment not found' , 404 )
513- if ( row . enrollment . status === 'revoked' ) {
514- throw new CredentialGroupEnrollmentError ( 'Revoked enrollment cannot be resent' , 409 )
515- }
516- return sendInvitation ( context , userId , inviterName , row . enrollment . email )
578+ return sendInvitation ( context , userId , inviterName , row . enrollment . email , {
579+ expectedEnrollmentId : enrollmentId ,
580+ revokedEnrollment : 'reject' ,
581+ } )
517582}
518583
519584export async function revokeCredentialGroupEnrollment (
@@ -522,7 +587,7 @@ export async function revokeCredentialGroupEnrollment(
522587 enrollmentId : string
523588) : Promise < CredentialGroupEnrollment > {
524589 const [ existing ] = await db
525- . select ( { enrollmentId : credentialGroupEnrollment . id } )
590+ . select ( { email : credentialGroupEnrollment . email } )
526591 . from ( credentialGroupEnrollment )
527592 . innerJoin ( credentialGroup , eq ( credentialGroup . id , credentialGroupEnrollment . credentialGroupId ) )
528593 . where (
@@ -536,6 +601,7 @@ export async function revokeCredentialGroupEnrollment(
536601 if ( ! existing ) throw new CredentialGroupEnrollmentError ( 'Enrollment not found' , 404 )
537602
538603 return db . transaction ( async ( tx ) => {
604+ await lockCredentialGroupInvitationTarget ( tx , groupId , existing . email )
539605 await lockCredentialGroupEnrollmentLifecycle ( tx , enrollmentId )
540606 const now = new Date ( )
541607 const [ revoked ] = await tx
0 commit comments