From ceb3c50a0671001188e12d9500dffa9840fd6416 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:49:18 -0400 Subject: [PATCH 1/2] fix: batch policy acceptance emails, daily [dev] [Marfuen] mariano/fix-policy-publish-notifications --- .../api/src/policies/policies.service.spec.ts | 219 +++++++++ apps/api/src/policies/policies.service.ts | 37 +- ...licy-acknowledgment-digest-helpers.test.ts | 69 +++ .../policy-acknowledgment-digest-helpers.ts | 32 ++ .../task/policy-acknowledgment-digest.test.ts | 442 ++++++++++++++++++ .../task/policy-acknowledgment-digest.ts | 198 ++++++++ .../emails/policy-acknowledgment-digest.tsx | 113 +++++ packages/email/emails/render.test.tsx | 31 +- packages/email/index.ts | 1 + packages/email/lib/check-unsubscribe.ts | 162 ++++++- 10 files changed, 1282 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/policies/policies.service.spec.ts create mode 100644 apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.test.ts create mode 100644 apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts create mode 100644 apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts create mode 100644 apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts create mode 100644 packages/email/emails/policy-acknowledgment-digest.tsx diff --git a/apps/api/src/policies/policies.service.spec.ts b/apps/api/src/policies/policies.service.spec.ts new file mode 100644 index 0000000000..07ab440b1a --- /dev/null +++ b/apps/api/src/policies/policies.service.spec.ts @@ -0,0 +1,219 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { PoliciesService } from './policies.service'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; + +jest.mock('@db', () => ({ + db: { + policy: { + findMany: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + }, + member: { + findMany: jest.fn(), + }, + auditLog: { + createMany: jest.fn(), + }, + $transaction: jest.fn(), + }, + Frequency: { + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', + }, + PolicyStatus: { + draft: 'draft', + published: 'published', + needs_review: 'needs_review', + }, + Prisma: { + PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, { code }: { code: string }) { + super(message); + this.code = code; + } + }, + }, +})); + +jest.mock('../utils/compliance-filters', () => ({ + filterComplianceMembers: jest.fn(async (members: unknown[]) => members), +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { db } = require('@db') as { + db: { + policy: { findMany: jest.Mock; findFirst: jest.Mock; update: jest.Mock }; + member: { findMany: jest.Mock }; + auditLog: { createMany: jest.Mock }; + $transaction: jest.Mock; + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { filterComplianceMembers: mockedFilterComplianceMembers } = require('../utils/compliance-filters') as { + filterComplianceMembers: jest.Mock; +}; + +describe('PoliciesService', () => { + let service: PoliciesService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PoliciesService, + { provide: AttachmentsService, useValue: {} }, + { provide: PolicyPdfRendererService, useValue: {} }, + ], + }).compile(); + service = module.get(PoliciesService); + }); + + describe('updateById', () => { + it('clears signedBy[] when the status transitions to published', async () => { + const orgId = 'org_abc'; + const existing = { id: 'pol_1', organizationId: orgId, status: 'draft' }; + const updatedResult = { ...existing, status: 'published', signedBy: [], name: 'Test Policy' }; + + // Make $transaction execute the callback with a tx proxy backed by db mocks + db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => { + const tx = { policy: { findFirst: db.policy.findFirst, update: db.policy.update } }; + return callback(tx); + }); + db.policy.findFirst.mockResolvedValueOnce(existing); + db.policy.update.mockResolvedValueOnce(updatedResult); + + await service.updateById('pol_1', orgId, { status: 'published' } as never); + + expect(db.policy.update).toHaveBeenCalledTimes(1); + const updateArg = db.policy.update.mock.calls[0][0]; + expect(updateArg.data.signedBy).toEqual([]); + expect(updateArg.data.status).toBe('published'); + expect(updateArg.data.lastPublishedAt).toBeInstanceOf(Date); + }); + + it('does not clear signedBy when the policy is already published and status is re-sent', async () => { + const orgId = 'org_abc'; + const existing = { id: 'pol_1', organizationId: orgId, status: 'published' }; + const updatedResult = { ...existing, description: 'tweak', name: 'Test' }; + + db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => { + const tx = { policy: { findFirst: db.policy.findFirst, update: db.policy.update } }; + return callback(tx); + }); + db.policy.findFirst.mockResolvedValueOnce(existing); + db.policy.update.mockResolvedValueOnce(updatedResult); + + await service.updateById('pol_1', orgId, { + status: 'published', + description: 'tweak', + } as never); + + const updateArg = db.policy.update.mock.calls[0][0]; + expect(updateArg.data.signedBy).toBeUndefined(); + expect(updateArg.data.lastPublishedAt).toBeUndefined(); + }); + + it('does not clear signedBy[] on non-publish updates', async () => { + const orgId = 'org_abc'; + const existing = { id: 'pol_1', organizationId: orgId, status: 'published', signedBy: ['usr_a'] }; + const updatedResult = { ...existing, description: 'new desc', name: 'Test Policy' }; + + db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => { + const tx = { policy: { findFirst: db.policy.findFirst, update: db.policy.update } }; + return callback(tx); + }); + db.policy.findFirst.mockResolvedValueOnce(existing); + db.policy.update.mockResolvedValueOnce(updatedResult); + + await service.updateById('pol_1', orgId, { description: 'new desc' } as never); + + const updateArg = db.policy.update.mock.calls[0][0]; + expect(updateArg.data.signedBy).toBeUndefined(); + }); + }); + + describe('publishAll', () => { + it('clears signedBy[] on every published policy and returns { success, publishedCount, members }', async () => { + const orgId = 'org_abc'; + const drafts = [ + { id: 'pol_1', name: 'Access', frequency: 'yearly' }, + { id: 'pol_2', name: 'Backup', frequency: null }, + ]; + db.policy.findMany.mockResolvedValueOnce(drafts); + db.$transaction.mockImplementation((updates: unknown[]) => Promise.resolve(updates)); + db.policy.update.mockImplementation((args) => args); + db.member.findMany.mockResolvedValueOnce([]); + + const result = await service.publishAll(orgId); + + expect(db.$transaction).toHaveBeenCalledTimes(1); + const txArg = db.$transaction.mock.calls[0][0] as Array<{ + where: { id: string }; + data: Record; + }>; + expect(txArg).toHaveLength(2); + for (const update of txArg) { + expect(update.data.status).toBe('published'); + expect(update.data.signedBy).toEqual([]); + expect(update.data.lastPublishedAt).toBeInstanceOf(Date); + } + expect(result.success).toBe(true); + expect(result.publishedCount).toBe(2); + expect(result.members).toEqual([]); + }); + + it('returns early with publishedCount 0 when there are no drafts', async () => { + db.policy.findMany.mockResolvedValueOnce([]); + const result = await service.publishAll('org_empty'); + expect(result).toEqual({ success: true, publishedCount: 0, members: [] }); + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it('returns only compliance-obligated members in the members array', async () => { + const orgId = 'org_abc'; + db.policy.findMany.mockResolvedValueOnce([ + { id: 'pol_1', name: 'P', frequency: 'yearly' }, + ]); + db.$transaction.mockImplementation((updates: unknown[]) => + Promise.resolve(updates), + ); + db.policy.update.mockImplementation((args) => args); + db.member.findMany.mockResolvedValueOnce([ + { + role: 'employee', + user: { email: 'alice@example.com', name: 'Alice', role: null }, + organization: { id: orgId, name: 'Acme' }, + }, + { + role: 'auditor', + user: { email: 'audit@example.com', name: 'Aud', role: null }, + organization: { id: orgId, name: 'Acme' }, + }, + ]); + // Mock filterComplianceMembers to return only Alice + mockedFilterComplianceMembers.mockResolvedValueOnce([ + { + role: 'employee', + user: { email: 'alice@example.com', name: 'Alice', role: null }, + organization: { id: orgId, name: 'Acme' }, + }, + ] as never); + + const result = await service.publishAll(orgId); + + expect(result.members).toEqual([ + { + email: 'alice@example.com', + userName: 'Alice', + organizationName: 'Acme', + organizationId: orgId, + }, + ]); + }); + }); +}); diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 7209143967..4e9a7010d8 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -8,6 +8,7 @@ import { db, Frequency, PolicyStatus, Prisma } from '@db'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import { AttachmentsService } from '../attachments/attachments.service'; import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; +import { filterComplianceMembers } from '../utils/compliance-filters'; import type { CreatePolicyDto } from './dto/create-policy.dto'; import type { UpdatePolicyDto } from './dto/update-policy.dto'; import type { @@ -117,12 +118,13 @@ export class PoliciesService { status: 'published', lastPublishedAt: now, reviewDate: computeNextReviewDate(p.frequency), + // Clear signatures — employees must re-acknowledge new content + signedBy: [], }, }), ), ); - // Create audit log entry for each published policy if (userId) { await db.auditLog.createMany({ data: draftPolicies.map((p) => ({ @@ -143,23 +145,23 @@ export class PoliciesService { }); } - // Fetch employee/contractor members for email notifications - const members = await db.member.findMany({ - where: { - organizationId, - deactivated: false, - role: { in: ['employee', 'contractor'] }, - }, + const allMembers = await db.member.findMany({ + where: { organizationId, deactivated: false }, include: { - user: { select: { email: true, name: true } }, + user: { select: { email: true, name: true, role: true } }, organization: { select: { name: true, id: true } }, }, }); + const complianceMembers = await filterComplianceMembers( + allMembers, + organizationId, + ); + return { success: true, publishedCount: draftPolicies.length, - members: members.map((m) => ({ + members: complianceMembers.map((m) => ({ email: m.user.email, userName: m.user.name || '', organizationName: m.organization.name || '', @@ -321,11 +323,6 @@ export class PoliciesService { // Prepare update data with special handling for status changes const updatePayload: Record = { ...updateData }; - // If status is being changed to published, update lastPublishedAt - if (updateData.status === 'published') { - updatePayload.lastPublishedAt = new Date(); - } - // If isArchived is being set to true, update lastArchivedAt if (updateData.isArchived === true) { updatePayload.lastArchivedAt = new Date(); @@ -360,6 +357,16 @@ export class PoliciesService { ); } + // Only clear signatures when actually transitioning to published. + // Re-sending the full object for an already-published policy must not wipe acknowledgments. + if ( + updateData.status === 'published' && + existingPolicy.status !== 'published' + ) { + updatePayload.lastPublishedAt = new Date(); + updatePayload.signedBy = []; + } + const policy = await tx.policy.update({ where: { id }, data: updatePayload, diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.test.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.test.ts new file mode 100644 index 0000000000..96025768d7 --- /dev/null +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { + computePendingPolicies, + type DigestMember, + type DigestPolicy, +} from './policy-acknowledgment-digest-helpers'; + +const alice: DigestMember = { + id: 'mem_alice', + role: 'employee', + department: 'it', + user: { id: 'usr_alice', name: 'Alice', email: 'alice@example.com' }, +}; +const bob: DigestMember = { + id: 'mem_bob', + role: 'employee', + department: 'hr', + user: { id: 'usr_bob', name: 'Bob', email: 'bob@example.com' }, +}; + +const allPolicy: DigestPolicy = { + id: 'pol_all', + name: 'Access Control', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], +}; +const itOnlyPolicy: DigestPolicy = { + id: 'pol_it', + name: 'IT Handbook', + signedBy: [], + visibility: 'DEPARTMENT', + visibleToDepartments: ['it'], +}; + +describe('computePendingPolicies', () => { + it('returns no pending policies when member has signed all applicable policies', () => { + const policies: DigestPolicy[] = [{ ...allPolicy, signedBy: ['usr_alice'] }]; + expect(computePendingPolicies(alice, policies)).toEqual([]); + }); + + it('returns policies where the member id is missing from signedBy[]', () => { + const policies: DigestPolicy[] = [ + { ...allPolicy, signedBy: ['usr_bob'] }, + { ...itOnlyPolicy, id: 'pol_2', name: 'Second', signedBy: ['usr_alice'] }, + ]; + expect(computePendingPolicies(alice, policies).map((p) => p.id)).toEqual(['pol_all']); + }); + + it('excludes DEPARTMENT-scoped policies when member department is not in the visible list', () => { + const policies: DigestPolicy[] = [itOnlyPolicy]; + expect(computePendingPolicies(bob, policies)).toEqual([]); + }); + + it('includes DEPARTMENT-scoped policies when member department matches', () => { + const policies: DigestPolicy[] = [itOnlyPolicy]; + expect(computePendingPolicies(alice, policies).map((p) => p.id)).toEqual(['pol_it']); + }); + + it('excludes DEPARTMENT-scoped policies when member has no department set', () => { + const memberNoDept: DigestMember = { ...alice, department: null }; + expect(computePendingPolicies(memberNoDept, [itOnlyPolicy])).toEqual([]); + }); + + it('returns empty array when there are no policies', () => { + expect(computePendingPolicies(alice, [])).toEqual([]); + }); +}); diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts new file mode 100644 index 0000000000..0a23a84a8f --- /dev/null +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts @@ -0,0 +1,32 @@ +/** + * Helper types and pure filter function for the policy acknowledgment digest. + * Extracted from the scheduled task for testability. + */ +import type { Departments, PolicyVisibility } from '@db'; + +export interface DigestPolicy { + id: string; + name: string; + signedBy: string[]; + visibility: PolicyVisibility; + visibleToDepartments: Departments[]; +} + +export interface DigestMember { + id: string; + role: string; + department: Departments | null; + user: { id: string; name: string | null; email: string; role?: string | null }; +} + +export function computePendingPolicies( + member: DigestMember, + policies: DigestPolicy[], +): DigestPolicy[] { + return policies.filter((policy) => { + if (policy.signedBy.includes(member.user.id)) return false; + if (policy.visibility === 'ALL') return true; + if (!member.department) return false; + return policy.visibleToDepartments.includes(member.department); + }); +} diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts new file mode 100644 index 0000000000..63a692c071 --- /dev/null +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.test.ts @@ -0,0 +1,442 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@db/server', () => ({ + db: { + organization: { findMany: vi.fn() }, + }, +})); + +vi.mock('@/lib/compliance', () => ({ + filterComplianceMembers: vi.fn(), +})); + +vi.mock('../../lib/send-email-via-api', () => ({ + sendEmailViaApi: vi.fn(), +})); + +vi.mock('@trycompai/email/lib/check-unsubscribe', () => ({ + getUnsubscribedEmails: vi.fn(), +})); + +vi.mock('@trigger.dev/sdk', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + schedules: { + task: (config: { run: (payload: unknown) => Promise }) => config, + }, +})); + +import { db } from '@db/server'; +import { filterComplianceMembers } from '@/lib/compliance'; +import { sendEmailViaApi } from '../../lib/send-email-via-api'; +import { getUnsubscribedEmails } from '@trycompai/email/lib/check-unsubscribe'; +import { policyAcknowledgmentDigest } from './policy-acknowledgment-digest'; + +const mockDb = db as unknown as { + organization: { findMany: ReturnType }; +}; +const mockFindMany = mockDb.organization.findMany; +const mockFilterComplianceMembers = vi.mocked(filterComplianceMembers); +const mockSendEmailViaApi = vi.mocked(sendEmailViaApi); +const mockGetUnsubscribedEmails = vi.mocked(getUnsubscribedEmails); + +// The mock replaces schedules.task with a passthrough that returns the config +// directly, so `.run` is available on the exported constant at runtime. +const taskUnderTest = policyAcknowledgmentDigest as unknown as { + run: (payload: unknown) => Promise<{ + success: boolean; + emailsSent: number; + emailsFailed: number; + orgsProcessed: number; + emailsSkippedUnsubscribed: number; + }>; +}; + +describe('policyAcknowledgmentDigest', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFilterComplianceMembers.mockImplementation(async (members) => members); + mockSendEmailViaApi.mockResolvedValue({ taskId: 'run_fake' }); + mockGetUnsubscribedEmails.mockResolvedValue(new Set()); + }); + + it('sends one email per member with their pending policies', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_1', + name: 'Acme', + policy: [ + { + id: 'pol_a', + name: 'Access Control', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + { + id: 'pol_b', + name: 'Backup', + signedBy: ['usr_alice'], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_alice', + department: 'it', + user: { + id: 'usr_alice', + name: 'Alice', + email: 'alice@example.com', + role: null, + }, + }, + ], + }, + ]); + + const result = await taskUnderTest.run({ + timestamp: new Date(), + } as never); + + expect(mockSendEmailViaApi).toHaveBeenCalledTimes(1); + const call = mockSendEmailViaApi.mock.calls[0][0]; + expect(call.to).toBe('alice@example.com'); + expect(call.subject).toBe('You have 1 policy to review at Acme'); + expect(call.organizationId).toBe('org_1'); + expect(result).toMatchObject({ + success: true, + emailsSent: 1, + emailsSkippedUnsubscribed: 0, + }); + }); + + it('skips members with zero pending policies', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_1', + name: 'Acme', + policy: [ + { + id: 'pol_a', + name: 'Access Control', + signedBy: ['usr_alice'], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_alice', + department: 'it', + user: { + id: 'usr_alice', + name: 'Alice', + email: 'alice@example.com', + role: null, + }, + }, + ], + }, + ]); + + const result = await taskUnderTest.run({ + timestamp: new Date(), + } as never); + + expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(result).toMatchObject({ success: true, emailsSent: 0 }); + }); + + it('skips members without the compliance obligation', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_1', + name: 'Acme', + policy: [ + { + id: 'pol_a', + name: 'Access Control', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_audit', + department: null, + user: { + id: 'usr_audit', + name: 'Auditor', + email: 'audit@example.com', + role: null, + }, + }, + ], + }, + ]); + mockFilterComplianceMembers.mockResolvedValueOnce([]); + + const result = await taskUnderTest.run({ + timestamp: new Date(), + } as never); + + expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(result).toMatchObject({ success: true, emailsSent: 0 }); + }); + + it('sends one email with multiple policies when a user has multiple pending policies in the same org', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_1', + name: 'Acme', + policy: [ + { + id: 'pol_a', + name: 'Access', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + { + id: 'pol_b', + name: 'Backup', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + { + id: 'pol_c', + name: 'Change Mgmt', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_alice', + department: 'it', + user: { + id: 'usr_alice', + name: 'Alice', + email: 'alice@example.com', + role: null, + }, + }, + ], + }, + ]); + + await taskUnderTest.run({ timestamp: new Date() } as never); + + expect(mockSendEmailViaApi).toHaveBeenCalledTimes(1); + expect(mockSendEmailViaApi.mock.calls[0][0].subject).toBe( + 'You have 3 policies to review at Acme', + ); + }); + + it('completes successfully when individual sends fail', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_1', + name: 'Acme', + policy: [ + { + id: 'pol_a', + name: 'Access', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_alice', + department: 'it', + user: { + id: 'usr_alice', + name: 'Alice', + email: 'alice@example.com', + role: null, + }, + }, + { + id: 'mem_bob', + department: 'hr', + user: { + id: 'usr_bob', + name: 'Bob', + email: 'bob@example.com', + role: null, + }, + }, + ], + }, + ]); + mockSendEmailViaApi + .mockRejectedValueOnce(new Error('Resend 500')) + .mockResolvedValueOnce({ taskId: 'run_ok' }); + + const result = await taskUnderTest.run({ + timestamp: new Date(), + } as never); + + expect(mockSendEmailViaApi).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + success: true, + emailsSent: 1, + emailsFailed: 1, + }); + }); + + it('skips members who have unsubscribed from policy notifications', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_1', + name: 'Acme', + policy: [ + { + id: 'pol_a', + name: 'Access Control', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_alice', + department: 'it', + user: { + id: 'usr_alice', + name: 'Alice', + email: 'alice@example.com', + role: null, + }, + }, + ], + }, + ]); + mockGetUnsubscribedEmails.mockResolvedValueOnce( + new Set(['alice@example.com']), + ); + + const result = await taskUnderTest.run({ timestamp: new Date() } as never); + + expect(mockSendEmailViaApi).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + emailsSent: 0, + emailsSkippedUnsubscribed: 1, + }); + }); + + it('sends a separate email per org when a user belongs to multiple orgs', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_1', + name: 'Acme', + policy: [ + { + id: 'pol_a', + name: 'A', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_1', + department: 'it', + user: { + id: 'usr_alice', + name: 'Alice', + email: 'alice@example.com', + role: null, + }, + }, + ], + }, + { + id: 'org_2', + name: 'Beta', + policy: [ + { + id: 'pol_b', + name: 'B', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members: [ + { + id: 'mem_2', + department: 'hr', + user: { + id: 'usr_alice', + name: 'Alice', + email: 'alice@example.com', + role: null, + }, + }, + ], + }, + ]); + + const result = await taskUnderTest.run({ timestamp: new Date() } as never); + + expect(sendEmailViaApi).toHaveBeenCalledTimes(2); + const orgs = mockSendEmailViaApi.mock.calls + .map((c) => (c[0] as { organizationId: string }).organizationId) + .sort(); + expect(orgs).toEqual(['org_1', 'org_2']); + expect(result).toMatchObject({ + success: true, + orgsProcessed: 2, + emailsSent: 2, + }); + }); + + it('sends emails in batches of up to 25', async () => { + // Create 60 members in one org, all with pending policies, all subscribed. + const members = Array.from({ length: 60 }, (_, i) => ({ + id: `mem_${i}`, + department: 'it', + user: { + id: `usr_${i}`, + name: `User ${i}`, + email: `user${i}@example.com`, + role: null, + }, + })); + + mockFindMany.mockResolvedValueOnce([ + { + id: 'org_big', + name: 'BigCo', + policy: [ + { + id: 'pol_a', + name: 'Policy A', + signedBy: [], + visibility: 'ALL', + visibleToDepartments: [], + }, + ], + members, + }, + ]); + + // All subscribed + mockGetUnsubscribedEmails.mockResolvedValueOnce(new Set()); + + const result = await taskUnderTest.run({ timestamp: new Date() } as never); + + expect(mockSendEmailViaApi).toHaveBeenCalledTimes(60); + expect(result).toMatchObject({ success: true, emailsSent: 60 }); + }); +}); diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts new file mode 100644 index 0000000000..b502f95f9f --- /dev/null +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest.ts @@ -0,0 +1,198 @@ +import { db } from '@db/server'; +import { logger, schedules } from '@trigger.dev/sdk'; + +import { filterComplianceMembers } from '@/lib/compliance'; +import { PolicyAcknowledgmentDigestEmail } from '@trycompai/email'; +import { getUnsubscribedEmails } from '@trycompai/email/lib/check-unsubscribe'; + +import { sendEmailViaApi } from '../../lib/send-email-via-api'; +import { + computePendingPolicies, + type DigestMember, +} from './policy-acknowledgment-digest-helpers'; + +const getPortalBase = () => + (process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai').replace( + /\/+$/, + '', + ); + +const EMAIL_BATCH_SIZE = 25; + +async function sendInBatches( + sends: Array<() => Promise>, +): Promise[]> { + const results: PromiseSettledResult[] = []; + for (let i = 0; i < sends.length; i += EMAIL_BATCH_SIZE) { + const chunk = sends.slice(i, i + EMAIL_BATCH_SIZE); + const chunkResults = await Promise.allSettled(chunk.map((fn) => fn())); + results.push(...chunkResults); + } + return results; +} + +export const policyAcknowledgmentDigest = schedules.task({ + id: 'policy-acknowledgment-digest', + machine: 'large-1x', + cron: '0 14 * * *', // Once daily at 14:00 UTC + maxDuration: 1000 * 60 * 15, // 15 minutes + run: async () => { + const organizations = await db.organization.findMany({ + where: { + policy: { + some: { + status: 'published', + isArchived: false, + isRequiredToSign: true, + }, + }, + }, + select: { + id: true, + name: true, + policy: { + where: { + status: 'published', + isArchived: false, + isRequiredToSign: true, + }, + select: { + id: true, + name: true, + signedBy: true, + visibility: true, + visibleToDepartments: true, + }, + }, + members: { + where: { deactivated: false }, + select: { + id: true, + role: true, + department: true, + user: { + select: { id: true, name: true, email: true, role: true }, + }, + }, + }, + }, + }); + + logger.info( + `Checking ${organizations.length} orgs for pending acknowledgments`, + ); + + const portalBase = getPortalBase(); + let emailsSent = 0; + let emailsFailed = 0; + let emailsSkippedUnsubscribed = 0; + let orgsProcessed = 0; + + for (const org of organizations) { + orgsProcessed += 1; + const complianceMembers = await filterComplianceMembers( + org.members, + org.id, + ); + + if (complianceMembers.length === 0) continue; + + // Compute pending policies for each member first (no sends yet) + type PendingEntry = { + member: DigestMember; + policies: Array<{ id: string; name: string; url: string }>; + subject: string; + emailElement: ReturnType; + }; + + const pending: PendingEntry[] = []; + const emailsWithPending: string[] = []; + + for (const member of complianceMembers) { + const pendingPolicies = computePendingPolicies(member, org.policy); + if (pendingPolicies.length === 0) continue; + + const policies = pendingPolicies.map((p) => ({ + id: p.id, + name: p.name, + url: `${portalBase}/${org.id}/policy/${p.id}`, + })); + const countLabel = + policies.length === 1 ? '1 policy' : `${policies.length} policies`; + const subject = `You have ${countLabel} to review at ${org.name}`; + + const emailElement = PolicyAcknowledgmentDigestEmail({ + email: member.user.email, + userName: member.user.name ?? '', + organizationName: org.name, + organizationId: org.id, + policies, + }); + + emailsWithPending.push(member.user.email); + pending.push({ member, policies, subject, emailElement }); + } + + if (pending.length === 0) continue; + + // Batch unsubscribe check — 3 DB queries total for this org + const unsubscribedEmails = await getUnsubscribedEmails( + db, + emailsWithPending, + 'policyNotifications', + org.id, + ); + + // Build thunks for subscribed members only + const sends: Array<() => Promise> = []; + + for (const entry of pending) { + if (unsubscribedEmails.has(entry.member.user.email)) { + logger.debug( + 'User unsubscribed from policy notifications, skipping', + { email: entry.member.user.email, orgId: org.id }, + ); + emailsSkippedUnsubscribed += 1; + continue; + } + + sends.push(() => + sendEmailViaApi({ + to: entry.member.user.email, + subject: entry.subject, + organizationId: org.id, + react: entry.emailElement!, + }), + ); + } + + const results = await sendInBatches(sends); + for (const r of results) { + if (r.status === 'fulfilled') emailsSent += 1; + else { + emailsFailed += 1; + logger.warn('Digest email failed', { + orgId: org.id, + error: + r.reason instanceof Error ? r.reason.message : String(r.reason), + }); + } + } + } + + logger.info('Digest complete', { + orgsProcessed, + emailsSent, + emailsFailed, + emailsSkippedUnsubscribed, + }); + + return { + success: true, + orgsProcessed, + emailsSent, + emailsFailed, + emailsSkippedUnsubscribed, + }; + }, +}); diff --git a/packages/email/emails/policy-acknowledgment-digest.tsx b/packages/email/emails/policy-acknowledgment-digest.tsx new file mode 100644 index 0000000000..21c68aca4d --- /dev/null +++ b/packages/email/emails/policy-acknowledgment-digest.tsx @@ -0,0 +1,113 @@ +import { + Body, + Button, + Container, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; +import { UnsubscribeLink } from '../components/unsubscribe-link'; +import { getUnsubscribeUrl } from '../lib/unsubscribe'; + +export interface PolicyAcknowledgmentDigestEmailProps { + email: string; + userName: string; + organizationName: string; + organizationId: string; + policies: { id: string; name: string; url: string }[]; +} + +export const PolicyAcknowledgmentDigestEmail = ({ + email, + userName, + organizationName, + organizationId, + policies, +}: PolicyAcknowledgmentDigestEmailProps) => { + if (policies.length === 0) return null; + + const portalBase = ( + process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai' + ).replace(/\/+$/, ''); + const portalLink = `${portalBase}/${organizationId}`; + const countLabel = policies.length === 1 ? '1 policy' : `${policies.length} policies`; + const subjectText = `You have ${countLabel} to review at ${organizationName}`; + + return ( + + + + {subjectText} + + + + + + {subjectText} + + + + Hi {userName || 'there'}, + + + + Your organization {organizationName} has {countLabel} awaiting your + review and acknowledgment: + + +
+ {policies.map((policy) => ( + + •{' '} + + {policy.name} + + + ))} +
+ +
+ +
+ + + or copy and paste this URL into your browser{' '} + + {portalLink} + + + +
+
+ + This notification was intended for {email}. + +
+ + + +
+ +