@@ -51,7 +51,8 @@ const taskUnderTest = policyAcknowledgmentDigest as unknown as {
5151 emailsSent : number ;
5252 emailsFailed : number ;
5353 orgsProcessed : number ;
54- emailsSkippedUnsubscribed : number ;
54+ recipients : number ;
55+ orgsSkippedUnsubscribed : number ;
5556 } > ;
5657} ;
5758
@@ -79,7 +80,7 @@ describe('policyAcknowledgmentDigest', () => {
7980 {
8081 id : 'pol_b' ,
8182 name : 'Backup' ,
82- signedBy : [ 'usr_alice ' ] ,
83+ signedBy : [ 'mem_alice ' ] ,
8384 visibility : 'ALL' ,
8485 visibleToDepartments : [ ] ,
8586 } ,
@@ -111,7 +112,7 @@ describe('policyAcknowledgmentDigest', () => {
111112 expect ( result ) . toMatchObject ( {
112113 success : true ,
113114 emailsSent : 1 ,
114- emailsSkippedUnsubscribed : 0 ,
115+ orgsSkippedUnsubscribed : 0 ,
115116 } ) ;
116117 } ) ;
117118
@@ -124,7 +125,7 @@ describe('policyAcknowledgmentDigest', () => {
124125 {
125126 id : 'pol_a' ,
126127 name : 'Access Control' ,
127- signedBy : [ 'usr_alice ' ] ,
128+ signedBy : [ 'mem_alice ' ] ,
128129 visibility : 'ALL' ,
129130 visibleToDepartments : [ ] ,
130131 } ,
@@ -333,11 +334,94 @@ describe('policyAcknowledgmentDigest', () => {
333334 expect ( result ) . toMatchObject ( {
334335 success : true ,
335336 emailsSent : 0 ,
336- emailsSkippedUnsubscribed : 1 ,
337+ orgsSkippedUnsubscribed : 1 ,
337338 } ) ;
338339 } ) ;
339340
340- it ( 'sends a separate email per org when a user belongs to multiple orgs' , async ( ) => {
341+ it ( 'rolls up pending policies across orgs into a single email when a user belongs to multiple orgs' , async ( ) => {
342+ mockFindMany . mockResolvedValueOnce ( [
343+ {
344+ id : 'org_1' ,
345+ name : 'Acme' ,
346+ policy : [
347+ {
348+ id : 'pol_a' ,
349+ name : 'Access Control' ,
350+ signedBy : [ ] ,
351+ visibility : 'ALL' ,
352+ visibleToDepartments : [ ] ,
353+ } ,
354+ ] ,
355+ members : [
356+ {
357+ id : 'mem_1' ,
358+ department : 'it' ,
359+ user : {
360+ id : 'usr_alice' ,
361+ name : 'Alice' ,
362+ email : 'alice@example.com' ,
363+ role : null ,
364+ } ,
365+ } ,
366+ ] ,
367+ } ,
368+ {
369+ id : 'org_2' ,
370+ name : 'Beta' ,
371+ policy : [
372+ {
373+ id : 'pol_b' ,
374+ name : 'Backup' ,
375+ signedBy : [ ] ,
376+ visibility : 'ALL' ,
377+ visibleToDepartments : [ ] ,
378+ } ,
379+ {
380+ id : 'pol_c' ,
381+ name : 'Change Mgmt' ,
382+ signedBy : [ ] ,
383+ visibility : 'ALL' ,
384+ visibleToDepartments : [ ] ,
385+ } ,
386+ ] ,
387+ members : [
388+ {
389+ id : 'mem_2' ,
390+ department : 'hr' ,
391+ user : {
392+ id : 'usr_alice' ,
393+ name : 'Alice' ,
394+ email : 'alice@example.com' ,
395+ role : null ,
396+ } ,
397+ } ,
398+ ] ,
399+ } ,
400+ ] ) ;
401+
402+ const result = await taskUnderTest . run ( { timestamp : new Date ( ) } as never ) ;
403+
404+ expect ( mockSendEmailViaApi ) . toHaveBeenCalledTimes ( 1 ) ;
405+ const call = mockSendEmailViaApi . mock . calls [ 0 ] [ 0 ] as {
406+ to : string ;
407+ subject : string ;
408+ organizationId : string ;
409+ } ;
410+ expect ( call . to ) . toBe ( 'alice@example.com' ) ;
411+ expect ( call . subject ) . toBe (
412+ 'You have 3 policies to review across 2 organizations' ,
413+ ) ;
414+ // x-organization-id falls back to the first org the user had policies in.
415+ expect ( call . organizationId ) . toBe ( 'org_1' ) ;
416+ expect ( result ) . toMatchObject ( {
417+ success : true ,
418+ orgsProcessed : 2 ,
419+ recipients : 1 ,
420+ emailsSent : 1 ,
421+ } ) ;
422+ } ) ;
423+
424+ it ( 'drops a single org from the rollup when the user is unsubscribed there, but still emails about other orgs' , async ( ) => {
341425 mockFindMany . mockResolvedValueOnce ( [
342426 {
343427 id : 'org_1' ,
@@ -390,21 +474,137 @@ describe('policyAcknowledgmentDigest', () => {
390474 ] ,
391475 } ,
392476 ] ) ;
477+ // Alice is unsubscribed from policy notifications in org_1 only.
478+ mockGetUnsubscribedEmails . mockImplementation (
479+ async ( _db , _emails , _pref , orgId ) =>
480+ orgId === 'org_1'
481+ ? new Set < string > ( [ 'alice@example.com' ] )
482+ : new Set < string > ( ) ,
483+ ) ;
393484
394485 const result = await taskUnderTest . run ( { timestamp : new Date ( ) } as never ) ;
395486
396- expect ( sendEmailViaApi ) . toHaveBeenCalledTimes ( 2 ) ;
397- const orgs = mockSendEmailViaApi . mock . calls
398- . map ( ( c ) => ( c [ 0 ] as { organizationId : string } ) . organizationId )
399- . sort ( ) ;
400- expect ( orgs ) . toEqual ( [ 'org_1' , 'org_2' ] ) ;
487+ expect ( mockSendEmailViaApi ) . toHaveBeenCalledTimes ( 1 ) ;
488+ const call = mockSendEmailViaApi . mock . calls [ 0 ] [ 0 ] as {
489+ subject : string ;
490+ organizationId : string ;
491+ } ;
492+ expect ( call . subject ) . toBe ( 'You have 1 policy to review at Beta' ) ;
493+ expect ( call . organizationId ) . toBe ( 'org_2' ) ;
401494 expect ( result ) . toMatchObject ( {
402495 success : true ,
403496 orgsProcessed : 2 ,
404- emailsSent : 2 ,
497+ recipients : 1 ,
498+ emailsSent : 1 ,
499+ orgsSkippedUnsubscribed : 1 ,
405500 } ) ;
406501 } ) ;
407502
503+ it ( 'does not send any email to a user who is unsubscribed in every org they belong to' , async ( ) => {
504+ mockFindMany . mockResolvedValueOnce ( [
505+ {
506+ id : 'org_1' ,
507+ name : 'Acme' ,
508+ policy : [
509+ {
510+ id : 'pol_a' ,
511+ name : 'A' ,
512+ signedBy : [ ] ,
513+ visibility : 'ALL' ,
514+ visibleToDepartments : [ ] ,
515+ } ,
516+ ] ,
517+ members : [
518+ {
519+ id : 'mem_1' ,
520+ department : 'it' ,
521+ user : {
522+ id : 'usr_alice' ,
523+ name : 'Alice' ,
524+ email : 'alice@example.com' ,
525+ role : null ,
526+ } ,
527+ } ,
528+ ] ,
529+ } ,
530+ {
531+ id : 'org_2' ,
532+ name : 'Beta' ,
533+ policy : [
534+ {
535+ id : 'pol_b' ,
536+ name : 'B' ,
537+ signedBy : [ ] ,
538+ visibility : 'ALL' ,
539+ visibleToDepartments : [ ] ,
540+ } ,
541+ ] ,
542+ members : [
543+ {
544+ id : 'mem_2' ,
545+ department : 'hr' ,
546+ user : {
547+ id : 'usr_alice' ,
548+ name : 'Alice' ,
549+ email : 'alice@example.com' ,
550+ role : null ,
551+ } ,
552+ } ,
553+ ] ,
554+ } ,
555+ ] ) ;
556+ mockGetUnsubscribedEmails . mockResolvedValue (
557+ new Set < string > ( [ 'alice@example.com' ] ) ,
558+ ) ;
559+
560+ const result = await taskUnderTest . run ( { timestamp : new Date ( ) } as never ) ;
561+
562+ expect ( mockSendEmailViaApi ) . not . toHaveBeenCalled ( ) ;
563+ expect ( result ) . toMatchObject ( {
564+ success : true ,
565+ recipients : 0 ,
566+ emailsSent : 0 ,
567+ orgsSkippedUnsubscribed : 2 ,
568+ } ) ;
569+ } ) ;
570+
571+ it ( 'does not email a multi-role member (e.g. owner,employee) who has already signed every policy' , async ( ) => {
572+ // Regression: signedBy stores member ids (from the portal accept action),
573+ // not user ids. The digest must compare against member.id, otherwise
574+ // every compliance-obligated member looks "pending" on every policy.
575+ mockFindMany . mockResolvedValueOnce ( [
576+ {
577+ id : 'org_1' ,
578+ name : 'Acme' ,
579+ policy : Array . from ( { length : 10 } , ( _ , i ) => ( {
580+ id : `pol_${ i } ` ,
581+ name : `Policy ${ i } ` ,
582+ signedBy : [ 'mem_owner_employee' ] ,
583+ visibility : 'ALL' ,
584+ visibleToDepartments : [ ] ,
585+ } ) ) ,
586+ members : [
587+ {
588+ id : 'mem_owner_employee' ,
589+ role : 'owner,employee' ,
590+ department : 'eng' ,
591+ user : {
592+ id : 'usr_owner_employee' ,
593+ name : 'Owner Employee' ,
594+ email : 'multi@example.com' ,
595+ role : null ,
596+ } ,
597+ } ,
598+ ] ,
599+ } ,
600+ ] ) ;
601+
602+ const result = await taskUnderTest . run ( { timestamp : new Date ( ) } as never ) ;
603+
604+ expect ( mockSendEmailViaApi ) . not . toHaveBeenCalled ( ) ;
605+ expect ( result ) . toMatchObject ( { success : true , emailsSent : 0 } ) ;
606+ } ) ;
607+
408608 it ( 'sends emails in batches of up to 25' , async ( ) => {
409609 // Create 60 members in one org, all with pending policies, all subscribed.
410610 const members = Array . from ( { length : 60 } , ( _ , i ) => ( {
@@ -443,4 +643,40 @@ describe('policyAcknowledgmentDigest', () => {
443643 expect ( mockSendEmailViaApi ) . toHaveBeenCalledTimes ( 60 ) ;
444644 expect ( result ) . toMatchObject ( { success : true , emailsSent : 60 } ) ;
445645 } ) ;
646+
647+ it ( 'filters out dead orgs at the DB query (hasAccess, onboardingCompleted, 90-day session activity)' , async ( ) => {
648+ mockFindMany . mockResolvedValueOnce ( [ ] ) ;
649+
650+ await taskUnderTest . run ( { timestamp : new Date ( ) } as never ) ;
651+
652+ expect ( mockFindMany ) . toHaveBeenCalledTimes ( 1 ) ;
653+ const args = mockFindMany . mock . calls [ 0 ] [ 0 ] as {
654+ where : {
655+ hasAccess ?: boolean ;
656+ onboardingCompleted ?: boolean ;
657+ members ?: {
658+ some ?: {
659+ deactivated ?: boolean ;
660+ user ?: {
661+ sessions ?: { some ?: { updatedAt ?: { gte ?: Date } } } ;
662+ } ;
663+ } ;
664+ } ;
665+ } ;
666+ } ;
667+
668+ expect ( args . where . hasAccess ) . toBe ( true ) ;
669+ expect ( args . where . onboardingCompleted ) . toBe ( true ) ;
670+ expect ( args . where . members ?. some ?. deactivated ) . toBe ( false ) ;
671+ const gte = args . where . members ?. some ?. user ?. sessions ?. some ?. updatedAt ?. gte ;
672+ expect ( gte ) . toBeInstanceOf ( Date ) ;
673+
674+ // Mirror the task's local-time setDate(-90) so the assertion survives
675+ // DST transitions during the 90-day window.
676+ const expected = new Date ( ) ;
677+ expected . setDate ( expected . getDate ( ) - 90 ) ;
678+ expect ( Math . abs ( ( gte as Date ) . getTime ( ) - expected . getTime ( ) ) ) . toBeLessThan (
679+ 5_000 ,
680+ ) ;
681+ } ) ;
446682} ) ;
0 commit comments