Skip to content

Commit 5f85389

Browse files
authored
Merge branch 'main' into lewis/comp-framework-controls
2 parents c39a7c5 + cee8ed4 commit 5f85389

File tree

10 files changed

+1424
-22
lines changed

10 files changed

+1424
-22
lines changed
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { Test, type TestingModule } from '@nestjs/testing';
2+
import { PoliciesService } from './policies.service';
3+
import { AttachmentsService } from '../attachments/attachments.service';
4+
import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service';
5+
6+
jest.mock('@db', () => ({
7+
db: {
8+
policy: {
9+
findMany: jest.fn(),
10+
findFirst: jest.fn(),
11+
update: jest.fn(),
12+
},
13+
member: {
14+
findMany: jest.fn(),
15+
},
16+
auditLog: {
17+
createMany: jest.fn(),
18+
},
19+
$transaction: jest.fn(),
20+
},
21+
Frequency: {
22+
monthly: 'monthly',
23+
quarterly: 'quarterly',
24+
yearly: 'yearly',
25+
},
26+
PolicyStatus: {
27+
draft: 'draft',
28+
published: 'published',
29+
needs_review: 'needs_review',
30+
},
31+
Prisma: {
32+
PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error {
33+
code: string;
34+
constructor(message: string, { code }: { code: string }) {
35+
super(message);
36+
this.code = code;
37+
}
38+
},
39+
},
40+
}));
41+
42+
jest.mock('../utils/compliance-filters', () => ({
43+
filterComplianceMembers: jest.fn(async (members: unknown[]) => members),
44+
}));
45+
46+
// eslint-disable-next-line @typescript-eslint/no-require-imports
47+
const { db } = require('@db') as {
48+
db: {
49+
policy: { findMany: jest.Mock; findFirst: jest.Mock; update: jest.Mock };
50+
member: { findMany: jest.Mock };
51+
auditLog: { createMany: jest.Mock };
52+
$transaction: jest.Mock;
53+
};
54+
};
55+
56+
// eslint-disable-next-line @typescript-eslint/no-require-imports
57+
const { filterComplianceMembers: mockedFilterComplianceMembers } = require('../utils/compliance-filters') as {
58+
filterComplianceMembers: jest.Mock;
59+
};
60+
61+
describe('PoliciesService', () => {
62+
let service: PoliciesService;
63+
64+
beforeEach(async () => {
65+
jest.clearAllMocks();
66+
const module: TestingModule = await Test.createTestingModule({
67+
providers: [
68+
PoliciesService,
69+
{ provide: AttachmentsService, useValue: {} },
70+
{ provide: PolicyPdfRendererService, useValue: {} },
71+
],
72+
}).compile();
73+
service = module.get<PoliciesService>(PoliciesService);
74+
});
75+
76+
describe('updateById', () => {
77+
it('clears signedBy[] when the status transitions to published', async () => {
78+
const orgId = 'org_abc';
79+
const existing = { id: 'pol_1', organizationId: orgId, status: 'draft' };
80+
const updatedResult = { ...existing, status: 'published', signedBy: [], name: 'Test Policy' };
81+
82+
// Make $transaction execute the callback with a tx proxy backed by db mocks
83+
db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
84+
const tx = { policy: { findFirst: db.policy.findFirst, update: db.policy.update } };
85+
return callback(tx);
86+
});
87+
db.policy.findFirst.mockResolvedValueOnce(existing);
88+
db.policy.update.mockResolvedValueOnce(updatedResult);
89+
90+
await service.updateById('pol_1', orgId, { status: 'published' } as never);
91+
92+
expect(db.policy.update).toHaveBeenCalledTimes(1);
93+
const updateArg = db.policy.update.mock.calls[0][0];
94+
expect(updateArg.data.signedBy).toEqual([]);
95+
expect(updateArg.data.status).toBe('published');
96+
expect(updateArg.data.lastPublishedAt).toBeInstanceOf(Date);
97+
});
98+
99+
it('does not clear signedBy when the policy is already published and status is re-sent', async () => {
100+
const orgId = 'org_abc';
101+
const existing = { id: 'pol_1', organizationId: orgId, status: 'published' };
102+
const updatedResult = { ...existing, description: 'tweak', name: 'Test' };
103+
104+
db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
105+
const tx = { policy: { findFirst: db.policy.findFirst, update: db.policy.update } };
106+
return callback(tx);
107+
});
108+
db.policy.findFirst.mockResolvedValueOnce(existing);
109+
db.policy.update.mockResolvedValueOnce(updatedResult);
110+
111+
await service.updateById('pol_1', orgId, {
112+
status: 'published',
113+
description: 'tweak',
114+
} as never);
115+
116+
const updateArg = db.policy.update.mock.calls[0][0];
117+
expect(updateArg.data.signedBy).toBeUndefined();
118+
expect(updateArg.data.lastPublishedAt).toBeUndefined();
119+
});
120+
121+
it('does not clear signedBy[] on non-publish updates', async () => {
122+
const orgId = 'org_abc';
123+
const existing = { id: 'pol_1', organizationId: orgId, status: 'published', signedBy: ['usr_a'] };
124+
const updatedResult = { ...existing, description: 'new desc', name: 'Test Policy' };
125+
126+
db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
127+
const tx = { policy: { findFirst: db.policy.findFirst, update: db.policy.update } };
128+
return callback(tx);
129+
});
130+
db.policy.findFirst.mockResolvedValueOnce(existing);
131+
db.policy.update.mockResolvedValueOnce(updatedResult);
132+
133+
await service.updateById('pol_1', orgId, { description: 'new desc' } as never);
134+
135+
const updateArg = db.policy.update.mock.calls[0][0];
136+
expect(updateArg.data.signedBy).toBeUndefined();
137+
});
138+
});
139+
140+
describe('publishAll', () => {
141+
it('clears signedBy[] on every published policy and returns { success, publishedCount, members }', async () => {
142+
const orgId = 'org_abc';
143+
const drafts = [
144+
{ id: 'pol_1', name: 'Access', frequency: 'yearly' },
145+
{ id: 'pol_2', name: 'Backup', frequency: null },
146+
];
147+
db.policy.findMany.mockResolvedValueOnce(drafts);
148+
db.$transaction.mockImplementation((updates: unknown[]) => Promise.resolve(updates));
149+
db.policy.update.mockImplementation((args) => args);
150+
db.member.findMany.mockResolvedValueOnce([]);
151+
152+
const result = await service.publishAll(orgId);
153+
154+
expect(db.$transaction).toHaveBeenCalledTimes(1);
155+
const txArg = db.$transaction.mock.calls[0][0] as Array<{
156+
where: { id: string };
157+
data: Record<string, unknown>;
158+
}>;
159+
expect(txArg).toHaveLength(2);
160+
for (const update of txArg) {
161+
expect(update.data.status).toBe('published');
162+
expect(update.data.signedBy).toEqual([]);
163+
expect(update.data.lastPublishedAt).toBeInstanceOf(Date);
164+
}
165+
expect(result.success).toBe(true);
166+
expect(result.publishedCount).toBe(2);
167+
expect(result.members).toEqual([]);
168+
});
169+
170+
it('returns early with publishedCount 0 when there are no drafts', async () => {
171+
db.policy.findMany.mockResolvedValueOnce([]);
172+
const result = await service.publishAll('org_empty');
173+
expect(result).toEqual({ success: true, publishedCount: 0, members: [] });
174+
expect(db.$transaction).not.toHaveBeenCalled();
175+
});
176+
177+
it('returns only compliance-obligated members in the members array', async () => {
178+
const orgId = 'org_abc';
179+
db.policy.findMany.mockResolvedValueOnce([
180+
{ id: 'pol_1', name: 'P', frequency: 'yearly' },
181+
]);
182+
db.$transaction.mockImplementation((updates: unknown[]) =>
183+
Promise.resolve(updates),
184+
);
185+
db.policy.update.mockImplementation((args) => args);
186+
db.member.findMany.mockResolvedValueOnce([
187+
{
188+
role: 'employee',
189+
user: { email: 'alice@example.com', name: 'Alice', role: null },
190+
organization: { id: orgId, name: 'Acme' },
191+
},
192+
{
193+
role: 'auditor',
194+
user: { email: 'audit@example.com', name: 'Aud', role: null },
195+
organization: { id: orgId, name: 'Acme' },
196+
},
197+
]);
198+
// Mock filterComplianceMembers to return only Alice
199+
mockedFilterComplianceMembers.mockResolvedValueOnce([
200+
{
201+
role: 'employee',
202+
user: { email: 'alice@example.com', name: 'Alice', role: null },
203+
organization: { id: orgId, name: 'Acme' },
204+
},
205+
] as never);
206+
207+
const result = await service.publishAll(orgId);
208+
209+
expect(result.members).toEqual([
210+
{
211+
email: 'alice@example.com',
212+
userName: 'Alice',
213+
organizationName: 'Acme',
214+
organizationId: orgId,
215+
},
216+
]);
217+
});
218+
});
219+
});

apps/api/src/policies/policies.service.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { db, Frequency, PolicyStatus, Prisma } from '@db';
88
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
99
import { AttachmentsService } from '../attachments/attachments.service';
1010
import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service';
11+
import { filterComplianceMembers } from '../utils/compliance-filters';
1112
import type { CreatePolicyDto } from './dto/create-policy.dto';
1213
import type { UpdatePolicyDto } from './dto/update-policy.dto';
1314
import type {
@@ -117,12 +118,13 @@ export class PoliciesService {
117118
status: 'published',
118119
lastPublishedAt: now,
119120
reviewDate: computeNextReviewDate(p.frequency),
121+
// Clear signatures — employees must re-acknowledge new content
122+
signedBy: [],
120123
},
121124
}),
122125
),
123126
);
124127

125-
// Create audit log entry for each published policy
126128
if (userId) {
127129
await db.auditLog.createMany({
128130
data: draftPolicies.map((p) => ({
@@ -143,23 +145,23 @@ export class PoliciesService {
143145
});
144146
}
145147

146-
// Fetch employee/contractor members for email notifications
147-
const members = await db.member.findMany({
148-
where: {
149-
organizationId,
150-
deactivated: false,
151-
role: { in: ['employee', 'contractor'] },
152-
},
148+
const allMembers = await db.member.findMany({
149+
where: { organizationId, deactivated: false },
153150
include: {
154-
user: { select: { email: true, name: true } },
151+
user: { select: { email: true, name: true, role: true } },
155152
organization: { select: { name: true, id: true } },
156153
},
157154
});
158155

156+
const complianceMembers = await filterComplianceMembers(
157+
allMembers,
158+
organizationId,
159+
);
160+
159161
return {
160162
success: true,
161163
publishedCount: draftPolicies.length,
162-
members: members.map((m) => ({
164+
members: complianceMembers.map((m) => ({
163165
email: m.user.email,
164166
userName: m.user.name || '',
165167
organizationName: m.organization.name || '',
@@ -321,11 +323,6 @@ export class PoliciesService {
321323
// Prepare update data with special handling for status changes
322324
const updatePayload: Record<string, unknown> = { ...updateData };
323325

324-
// If status is being changed to published, update lastPublishedAt
325-
if (updateData.status === 'published') {
326-
updatePayload.lastPublishedAt = new Date();
327-
}
328-
329326
// If isArchived is being set to true, update lastArchivedAt
330327
if (updateData.isArchived === true) {
331328
updatePayload.lastArchivedAt = new Date();
@@ -360,6 +357,16 @@ export class PoliciesService {
360357
);
361358
}
362359

360+
// Only clear signatures when actually transitioning to published.
361+
// Re-sending the full object for an already-published policy must not wipe acknowledgments.
362+
if (
363+
updateData.status === 'published' &&
364+
existingPolicy.status !== 'published'
365+
) {
366+
updatePayload.lastPublishedAt = new Date();
367+
updatePayload.signedBy = [];
368+
}
369+
363370
const policy = await tx.policy.update({
364371
where: { id },
365372
data: updatePayload,

0 commit comments

Comments
 (0)