Skip to content

Commit 78f36f0

Browse files
authored
Merge branch 'main' into lewis/comp-framework-controls
2 parents 5de8a67 + 5e771bc commit 78f36f0

7 files changed

Lines changed: 520 additions & 131 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## [3.23.6](https://github.com/trycompai/comp/compare/v3.23.5...v3.23.6) (2026-04-18)
2+
3+
4+
### Bug Fixes
5+
6+
* **digest:** correct signedBy check, rollup per user, skip dead orgs ([7269f59](https://github.com/trycompai/comp/commit/7269f5903aaf7cee18ae4b20ff5022f5455852b1))
7+
18
## [3.23.5](https://github.com/trycompai/comp/compare/v3.23.4...v3.23.5) (2026-04-17)
29

310

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)