Skip to content

Commit cee8ed4

Browse files
Marfuenclaude
andauthored
fix(digest): inline compliance filter to avoid trigger.dev bundle failure (#2591)
The Trigger.dev deploy pipeline cannot resolve @trycompai/auth because its dist is not built during the CI deploy step. Remove the transitive dependency by inlining the compliance-obligation filter directly in the helpers file, keeping identical behaviour. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ceb3c50 commit cee8ed4

4 files changed

Lines changed: 152 additions & 10 deletions

File tree

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import {
44
computePendingPolicies,
5+
filterDigestMembersByCompliance,
56
type DigestMember,
67
type DigestPolicy,
78
} from './policy-acknowledgment-digest-helpers';
@@ -67,3 +68,61 @@ describe('computePendingPolicies', () => {
6768
expect(computePendingPolicies(alice, [])).toEqual([]);
6869
});
6970
});
71+
72+
describe('filterDigestMembersByCompliance', () => {
73+
const mockDb = {
74+
organizationRole: { findMany: vi.fn() },
75+
};
76+
77+
beforeEach(() => {
78+
mockDb.organizationRole.findMany.mockReset();
79+
mockDb.organizationRole.findMany.mockResolvedValue([]);
80+
});
81+
82+
it('keeps members whose built-in role has the compliance obligation', async () => {
83+
const member: DigestMember = {
84+
id: 'm1', role: 'employee', department: 'it',
85+
user: { id: 'u1', name: 'A', email: 'a@x', role: null },
86+
};
87+
const result = await filterDigestMembersByCompliance(mockDb, [member], 'org_1');
88+
expect(result).toEqual([member]);
89+
});
90+
91+
it('drops members whose only role lacks the compliance obligation', async () => {
92+
const member: DigestMember = {
93+
id: 'm2', role: 'auditor', department: null,
94+
user: { id: 'u2', name: 'B', email: 'b@x', role: null },
95+
};
96+
const result = await filterDigestMembersByCompliance(mockDb, [member], 'org_1');
97+
expect(result).toEqual([]);
98+
});
99+
100+
it('drops platform admins even when member role has the obligation', async () => {
101+
const member: DigestMember = {
102+
id: 'm3', role: 'employee', department: 'it',
103+
user: { id: 'u3', name: 'C', email: 'c@x', role: 'admin' },
104+
};
105+
const result = await filterDigestMembersByCompliance(mockDb, [member], 'org_1');
106+
expect(result).toEqual([]);
107+
});
108+
109+
it('resolves custom-role obligations via organizationRole lookup', async () => {
110+
mockDb.organizationRole.findMany.mockResolvedValueOnce([
111+
{ name: 'contributor', obligations: { compliance: true } },
112+
]);
113+
const keeper: DigestMember = {
114+
id: 'm4', role: 'contributor', department: 'hr',
115+
user: { id: 'u4', name: 'D', email: 'd@x', role: null },
116+
};
117+
const dropper: DigestMember = {
118+
id: 'm5', role: 'observer', department: 'hr',
119+
user: { id: 'u5', name: 'E', email: 'e@x', role: null },
120+
};
121+
const result = await filterDigestMembersByCompliance(
122+
mockDb,
123+
[keeper, dropper],
124+
'org_1',
125+
);
126+
expect(result.map((m) => m.id)).toEqual(['m4']);
127+
});
128+
});

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,83 @@
44
*/
55
import type { Departments, PolicyVisibility } from '@db';
66

7+
// Inlined from @trycompai/auth to avoid pulling that package into the Trigger.dev bundle.
8+
// Keep in sync with packages/auth/src/permissions.ts BUILT_IN_ROLE_OBLIGATIONS.
9+
const BUILT_IN_ROLE_OBLIGATIONS: Record<string, { compliance?: boolean }> = {
10+
owner: { compliance: true },
11+
admin: { compliance: true },
12+
auditor: {},
13+
employee: { compliance: true },
14+
contractor: { compliance: true },
15+
};
16+
const BUILT_IN_ROLE_NAMES = new Set(Object.keys(BUILT_IN_ROLE_OBLIGATIONS));
17+
18+
export interface ComplianceFilterDb {
19+
organizationRole: {
20+
findMany: (args: {
21+
where: { organizationId: string; name: { in: string[] } };
22+
select: { name: true; obligations: true };
23+
}) => Promise<Array<{ name: string; obligations: unknown }>>;
24+
};
25+
}
26+
27+
/**
28+
* Filter members to only those with the compliance obligation.
29+
* Inlined equivalent of apps/app/src/lib/compliance.ts#filterComplianceMembers
30+
* so this file has no transitive dependency on @trycompai/auth (which cannot
31+
* be bundled by the Trigger.dev deploy pipeline).
32+
*/
33+
export async function filterDigestMembersByCompliance<T extends DigestMember>(
34+
db: ComplianceFilterDb,
35+
members: T[],
36+
organizationId: string,
37+
): Promise<T[]> {
38+
if (members.length === 0) return [];
39+
40+
const customRoleNames = new Set<string>();
41+
const parsed = members.map((member) => {
42+
const roleNames = member.role
43+
.split(',')
44+
.map((r) => r.trim())
45+
.filter(Boolean);
46+
for (const name of roleNames) {
47+
if (!BUILT_IN_ROLE_NAMES.has(name)) customRoleNames.add(name);
48+
}
49+
return { member, roleNames };
50+
});
51+
52+
let customObligationMap: Record<string, { compliance?: boolean }> = {};
53+
if (customRoleNames.size > 0) {
54+
const customRoles = await db.organizationRole.findMany({
55+
where: { organizationId, name: { in: [...customRoleNames] } },
56+
select: { name: true, obligations: true },
57+
});
58+
customObligationMap = Object.fromEntries(
59+
customRoles.map((r) => {
60+
const obligations =
61+
typeof r.obligations === 'string'
62+
? JSON.parse(r.obligations)
63+
: r.obligations || {};
64+
return [r.name, obligations as { compliance?: boolean }];
65+
}),
66+
);
67+
}
68+
69+
return parsed
70+
.filter(({ member, roleNames }) => {
71+
// Platform admins are excluded — matches the @/lib/compliance behavior.
72+
if (member.user?.role === 'admin') return false;
73+
for (const name of roleNames) {
74+
const builtIn = BUILT_IN_ROLE_OBLIGATIONS[name];
75+
if (builtIn?.compliance) return true;
76+
const custom = customObligationMap[name];
77+
if (custom?.compliance) return true;
78+
}
79+
return false;
80+
})
81+
.map(({ member }) => member);
82+
}
83+
784
export interface DigestPolicy {
885
id: string;
986
name: string;

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ vi.mock('@db/server', () => ({
66
},
77
}));
88

9-
vi.mock('@/lib/compliance', () => ({
10-
filterComplianceMembers: vi.fn(),
11-
}));
9+
vi.mock('./policy-acknowledgment-digest-helpers', async (importOriginal) => {
10+
const mod = await importOriginal<typeof import('./policy-acknowledgment-digest-helpers')>();
11+
return {
12+
...mod,
13+
filterDigestMembersByCompliance: vi.fn().mockImplementation(async (_db: unknown, members: unknown[]) => members),
14+
};
15+
});
1216

1317
vi.mock('../../lib/send-email-via-api', () => ({
1418
sendEmailViaApi: vi.fn(),
@@ -26,7 +30,7 @@ vi.mock('@trigger.dev/sdk', () => ({
2630
}));
2731

2832
import { db } from '@db/server';
29-
import { filterComplianceMembers } from '@/lib/compliance';
33+
import { filterDigestMembersByCompliance } from './policy-acknowledgment-digest-helpers';
3034
import { sendEmailViaApi } from '../../lib/send-email-via-api';
3135
import { getUnsubscribedEmails } from '@trycompai/email/lib/check-unsubscribe';
3236
import { policyAcknowledgmentDigest } from './policy-acknowledgment-digest';
@@ -35,7 +39,7 @@ const mockDb = db as unknown as {
3539
organization: { findMany: ReturnType<typeof vi.fn> };
3640
};
3741
const mockFindMany = mockDb.organization.findMany;
38-
const mockFilterComplianceMembers = vi.mocked(filterComplianceMembers);
42+
const mockFilterDigestMembersByCompliance = vi.mocked(filterDigestMembersByCompliance);
3943
const mockSendEmailViaApi = vi.mocked(sendEmailViaApi);
4044
const mockGetUnsubscribedEmails = vi.mocked(getUnsubscribedEmails);
4145

@@ -54,7 +58,7 @@ const taskUnderTest = policyAcknowledgmentDigest as unknown as {
5458
describe('policyAcknowledgmentDigest', () => {
5559
beforeEach(() => {
5660
vi.clearAllMocks();
57-
mockFilterComplianceMembers.mockImplementation(async (members) => members);
61+
mockFilterDigestMembersByCompliance.mockImplementation(async (_db, members) => members);
5862
mockSendEmailViaApi.mockResolvedValue({ taskId: 'run_fake' });
5963
mockGetUnsubscribedEmails.mockResolvedValue(new Set<string>());
6064
});
@@ -176,7 +180,7 @@ describe('policyAcknowledgmentDigest', () => {
176180
],
177181
},
178182
]);
179-
mockFilterComplianceMembers.mockResolvedValueOnce([]);
183+
mockFilterDigestMembersByCompliance.mockResolvedValueOnce([]);
180184

181185
const result = await taskUnderTest.run({
182186
timestamp: new Date(),

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { db } from '@db/server';
22
import { logger, schedules } from '@trigger.dev/sdk';
33

4-
import { filterComplianceMembers } from '@/lib/compliance';
54
import { PolicyAcknowledgmentDigestEmail } from '@trycompai/email';
65
import { getUnsubscribedEmails } from '@trycompai/email/lib/check-unsubscribe';
76

87
import { sendEmailViaApi } from '../../lib/send-email-via-api';
98
import {
109
computePendingPolicies,
10+
filterDigestMembersByCompliance,
11+
type ComplianceFilterDb,
1112
type DigestMember,
1213
} from './policy-acknowledgment-digest-helpers';
1314

@@ -90,7 +91,8 @@ export const policyAcknowledgmentDigest = schedules.task({
9091

9192
for (const org of organizations) {
9293
orgsProcessed += 1;
93-
const complianceMembers = await filterComplianceMembers<DigestMember>(
94+
const complianceMembers = await filterDigestMembersByCompliance(
95+
db as unknown as ComplianceFilterDb,
9496
org.members,
9597
org.id,
9698
);

0 commit comments

Comments
 (0)