Skip to content

Commit 7269f59

Browse files
github-actions[bot]Marfuenclaude
authored
fix(digest): correct signedBy check, rollup per user, skip dead orgs
* fix(digest): correct signedBy check, rollup per user, skip dead orgs The helper compared policy.signedBy[] against member.user.id, but signedBy stores member ids (written by the portal accept action and read by every other consumer). Users who had signed their policies were still receiving daily digest emails — the comparison never matched. Switched to member.id with an inline regression test covering the reported owner+employee case. Also roll up pending policies across orgs by user.id so a person with memberships in multiple orgs receives one email with per-org sections instead of one email per org. Per-org unsubscribe is still honored: a user opted out of policy notifications in org A only drops that org from the rollup and still gets an email about org B. Finally, match weekly-task-reminder's dead-org filter at the DB query — hasAccess, onboardingCompleted, and at least one non-deactivated member with a session updated in the last 90 days — so we stop sending to abandoned orgs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(digest): guard array[0] access for noUncheckedIndexedAccess DTS build failed because noUncheckedIndexedAccess types array[0] as possibly undefined, even right after a length check. Destructure and narrow with a truthy guard so strict TS accepts the template and the shared subject helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a834eaf commit 7269f59

6 files changed

Lines changed: 513 additions & 131 deletions

File tree

apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,21 @@ const itOnlyPolicy: DigestPolicy = {
3737

3838
describe('computePendingPolicies', () => {
3939
it('returns no pending policies when member has signed all applicable policies', () => {
40-
const policies: DigestPolicy[] = [{ ...allPolicy, signedBy: ['usr_alice'] }];
40+
const policies: DigestPolicy[] = [{ ...allPolicy, signedBy: ['mem_alice'] }];
4141
expect(computePendingPolicies(alice, policies)).toEqual([]);
4242
});
4343

4444
it('returns policies where the member id is missing from signedBy[]', () => {
4545
const policies: DigestPolicy[] = [
46-
{ ...allPolicy, signedBy: ['usr_bob'] },
47-
{ ...itOnlyPolicy, id: 'pol_2', name: 'Second', signedBy: ['usr_alice'] },
46+
{ ...allPolicy, signedBy: ['mem_bob'] },
47+
{ ...itOnlyPolicy, id: 'pol_2', name: 'Second', signedBy: ['mem_alice'] },
48+
];
49+
expect(computePendingPolicies(alice, policies).map((p) => p.id)).toEqual(['pol_all']);
50+
});
51+
52+
it('does not treat the member user id as a signature (signedBy stores member ids, not user ids)', () => {
53+
const policies: DigestPolicy[] = [
54+
{ ...allPolicy, signedBy: ['usr_alice'] },
4855
];
4956
expect(computePendingPolicies(alice, policies).map((p) => p.id)).toEqual(['pol_all']);
5057
});

apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ export function computePendingPolicies(
101101
policies: DigestPolicy[],
102102
): DigestPolicy[] {
103103
return policies.filter((policy) => {
104-
if (policy.signedBy.includes(member.user.id)) return false;
104+
// signedBy stores member ids (see apps/portal/src/actions/accept-policies.ts),
105+
// not user ids — every other consumer checks against member.id.
106+
if (policy.signedBy.includes(member.id)) return false;
105107
if (policy.visibility === 'ALL') return true;
106108
if (!member.department) return false;
107109
return policy.visibleToDepartments.includes(member.department);

apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts

Lines changed: 248 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)