Skip to content

Commit fecb556

Browse files
authored
fix(training): defer completion email and share canonical training IDs (#3529)
* fix(training): defer completion email and share canonical training IDs The portal's complete-training route awaited the completion-certificate email before responding. That API call resolves the member, renders a PDF and calls the email provider, so every final-video completion made the employee wait seconds for work whose outcome they never see — and Node's fetch has no default request timeout, so an unreachable API could stall the response for minutes even though the completion was already durable. Defer it with `after` so it runs once the response is sent, and bound the request with an AbortSignal timeout so a hung API can't pin the invocation open. The email path performs no DB or storage writes, only an in-memory PDF render and a send, so nothing the UI reads afterwards depends on it having finished. The route also restated the valid-video-ID list and the HIPAA eligibility rule that apps/api owns, a fifth copy of constants already duplicated across both apps and two API modules. Move them to @trycompai/company — the existing shared-definitions package for api/app/portal — so the two completion paths cannot disagree about which videos exist or which orgs may complete HIPAA training. The two apps' training data files link to the canonical IDs by type only. They are imported by client components and by a Trigger.dev task, and @trycompai/company requires @trycompai/db at runtime, so a value re-export would have pulled Prisma into those bundles. The type-level tie still fails the build on drift, in both directions: an unknown ID in a rendered list, or a canonical ID that no longer matches. Also fixes the pre-existing training-hipaa spec, whose @db mock was never given frameworkInstance when the HIPAA gate was added, and adds coverage for the gate's rejection path. * fix(training): make the canonical/rendered video lists exhaustive both ways `satisfies readonly CanonicalTrainingVideo[]` only proved that every rendered video is canonical. The reverse — a canonical ID with no rendered video — still compiled, and that is the dangerous direction: completion requires a row for every canonical ID, so adding one without a matching video would leave employees permanently unable to finish training. Key the data by the canonical union instead. `Record<GeneralTrainingVideoId, ...>` is exhaustive by construction, so both drifts now fail the build: a missing key (TS2741) and an unknown key (TS2353). Verified by introducing each drift and compiling. The `Exclude<GeneralTrainingVideoId, (typeof trainingVideos)[number]['id']>` form would not have worked here — the `readonly TrainingVideo[]` annotation widens `id` to `string`, so the Exclude resolves to `never` and the guard passes vacuously.
1 parent 0d79093 commit fecb556

11 files changed

Lines changed: 231 additions & 79 deletions

File tree

‎apps/api/src/frameworks/frameworks-people-score.helper.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { BackgroundCheckStatus, db } from '@db';
2+
import {
3+
GENERAL_TRAINING_VIDEO_IDS as GENERAL_TRAINING_IDS,
4+
HIPAA_TRAINING_ID,
5+
} from '@trycompai/company';
26
import { filterComplianceMembers } from '../utils/compliance-filters';
37

4-
const GENERAL_TRAINING_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];
5-
const HIPAA_TRAINING_ID = 'hipaa-sat-1';
68
const COMPLETED_BACKGROUND_CHECK_STATUSES = [
79
BackgroundCheckStatus.completed,
810
BackgroundCheckStatus.completed_with_flags,

‎apps/api/src/training/training-hipaa.spec.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { TrainingService } from './training.service';
44
jest.mock('@db', () => ({
55
db: {
66
member: { findFirst: jest.fn(), findUnique: jest.fn() },
7+
// markVideoComplete gates hipaa-sat-1 on the org having a HIPAA framework
8+
// instance, so the HIPAA paths must be able to resolve one.
9+
frameworkInstance: { findFirst: jest.fn() },
710
employeeTrainingVideoCompletion: {
811
findMany: jest.fn(),
912
findFirst: jest.fn(),
@@ -36,6 +39,7 @@ describe('TrainingService — HIPAA training', () => {
3639

3740
describe('markVideoComplete', () => {
3841
it('should accept hipaa-sat-1 as a valid training ID', async () => {
42+
db.frameworkInstance.findFirst.mockResolvedValue({ id: 'frm_1' });
3943
db.member.findFirst.mockResolvedValue({
4044
id: 'mem_1',
4145
organizationId: 'org_1',
@@ -65,6 +69,19 @@ describe('TrainingService — HIPAA training', () => {
6569
});
6670
});
6771

72+
// Mirrors the portal's complete-training route: the two completion paths
73+
// must agree on which orgs may complete HIPAA training, or one can create
74+
// records — and certificate artifacts — the other would have rejected.
75+
it('should reject hipaa-sat-1 when the org has no HIPAA framework', async () => {
76+
db.frameworkInstance.findFirst.mockResolvedValue(null);
77+
78+
await expect(
79+
service.markVideoComplete('mem_1', 'org_1', 'hipaa-sat-1'),
80+
).rejects.toThrow(BadRequestException);
81+
82+
expect(db.employeeTrainingVideoCompletion.create).not.toHaveBeenCalled();
83+
});
84+
6885
it('should reject unknown training IDs', async () => {
6986
await expect(
7087
service.markVideoComplete('mem_1', 'org_1', 'unknown-id'),

‎apps/api/src/training/training.service.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import {
55
BadRequestException,
66
} from '@nestjs/common';
77
import { db } from '@db';
8+
import {
9+
GENERAL_TRAINING_VIDEO_IDS as GENERAL_TRAINING_IDS,
10+
HIPAA_TRAINING_ID,
11+
HIPAA_TRAINING_UNAVAILABLE_MESSAGE,
12+
hipaaFrameworkInstanceWhere,
13+
isTrainingVideoId,
14+
} from '@trycompai/company';
815
import { TrainingEmailService } from './training-email.service';
916
import { TrainingCertificatePdfService } from './training-certificate-pdf.service';
1017

11-
const GENERAL_TRAINING_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];
12-
const HIPAA_TRAINING_ID = 'hipaa-sat-1';
13-
const ALL_TRAINING_IDS = [...GENERAL_TRAINING_IDS, HIPAA_TRAINING_ID];
14-
1518
@Injectable()
1619
export class TrainingService {
1720
private readonly logger = new Logger(TrainingService.name);
@@ -40,19 +43,17 @@ export class TrainingService {
4043
organizationId: string,
4144
videoId: string,
4245
) {
43-
if (!ALL_TRAINING_IDS.includes(videoId)) {
46+
if (!isTrainingVideoId(videoId)) {
4447
throw new BadRequestException(`Invalid video ID: ${videoId}`);
4548
}
4649

4750
if (videoId === HIPAA_TRAINING_ID) {
4851
const hipaaInstance = await db.frameworkInstance.findFirst({
49-
where: { organizationId, framework: { name: 'HIPAA' } },
52+
where: hipaaFrameworkInstanceWhere(organizationId),
5053
select: { id: true },
5154
});
5255
if (!hipaaInstance) {
53-
throw new BadRequestException(
54-
'HIPAA training is not available for this organization',
55-
);
56+
throw new BadRequestException(HIPAA_TRAINING_UNAVAILABLE_MESSAGE);
5657
}
5758
}
5859

@@ -116,7 +117,7 @@ export class TrainingService {
116117
const completions = await db.employeeTrainingVideoCompletion.findMany({
117118
where: {
118119
memberId,
119-
videoId: { in: GENERAL_TRAINING_IDS },
120+
videoId: { in: [...GENERAL_TRAINING_IDS] },
120121
completedAt: { not: null },
121122
},
122123
});
@@ -140,7 +141,7 @@ export class TrainingService {
140141
const completions = await db.employeeTrainingVideoCompletion.findMany({
141142
where: {
142143
memberId,
143-
videoId: { in: GENERAL_TRAINING_IDS },
144+
videoId: { in: [...GENERAL_TRAINING_IDS] },
144145
completedAt: { not: null },
145146
},
146147
orderBy: { completedAt: 'desc' },
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1-
export const HIPAA_TRAINING_ID = 'hipaa-sat-1';
1+
import type { TrainingVideoId } from '@trycompai/company';
2+
3+
// Tied to the canonical ID that both training-completion paths validate
4+
// against. Deliberately a local literal with a type-only link rather than a
5+
// re-export: client components import this file, and @trycompai/company pulls
6+
// in @trycompai/db at runtime. The annotation still fails typecheck if the
7+
// canonical ID ever changes, so the two cannot drift.
8+
export const HIPAA_TRAINING_ID: Extract<TrainingVideoId, 'hipaa-sat-1'> = 'hipaa-sat-1';
Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { GeneralTrainingVideoId } from '@trycompai/company';
2+
13
/**
24
* Represents a training video resource that can be used
35
* for user education and compliance training.
@@ -15,40 +17,53 @@ export interface TrainingVideo {
1517
url: string;
1618
}
1719

18-
export const trainingVideos: readonly TrainingVideo[] = [
19-
{
20-
id: 'sat-1',
20+
/**
21+
* Videos keyed by the canonical IDs that `@trycompai/company` owns, shared
22+
* with the NestJS training service and the portal's complete-training route.
23+
*
24+
* Keying by the canonical union makes the lists exhaustive in BOTH directions:
25+
* a canonical ID with no entry here fails to compile, and an entry that isn't
26+
* canonical fails too. Both matter — completion requires a row for every
27+
* canonical ID, so a video we never render would leave employees permanently
28+
* unable to finish training, while a rendered video the API rejects would 400.
29+
*
30+
* The import is type-only on purpose: client components and a Trigger.dev task
31+
* import this file, and `@trycompai/company` requires `@trycompai/db` at
32+
* runtime.
33+
*/
34+
const trainingVideosById: Record<GeneralTrainingVideoId, Omit<TrainingVideo, 'id'>> = {
35+
'sat-1': {
2136
title: 'Security Awareness Training - Part 1',
2237
description: 'Security Awareness Training - Part 1',
2338
youtubeId: 'N-sBS3uCWB4',
2439
url: 'https://www.youtube.com/watch?v=N-sBS3uCWB4',
2540
},
26-
{
27-
id: 'sat-2',
41+
'sat-2': {
2842
title: 'Security Awareness Training - Part 2',
2943
description: 'Security Awareness Training - Part 2',
3044
youtubeId: 'JwQNwhDyXig',
3145
url: 'https://www.youtube.com/watch?v=JwQNwhDyXig',
3246
},
33-
{
34-
id: 'sat-3',
47+
'sat-3': {
3548
title: 'Security Awareness Training - Part 3',
3649
description: 'Security Awareness Training - Part 3',
3750
youtubeId: 'fzMNw_-KEGE',
3851
url: 'https://www.youtube.com/watch?v=fzMNw_-KEGE',
3952
},
40-
{
41-
id: 'sat-4',
53+
'sat-4': {
4254
title: 'Security Awareness Training - Part 4',
4355
description: 'Security Awareness Training - Part 4',
4456
youtubeId: 'WbpqjH9kI2Y',
4557
url: 'https://www.youtube.com/watch?v=WbpqjH9kI2Y',
4658
},
47-
{
48-
id: 'sat-5',
59+
'sat-5': {
4960
title: 'Security Awareness Training - Part 5',
5061
description: 'Security Awareness Training - Part 5',
5162
youtubeId: 'Clvfkm6azDs',
5263
url: 'https://www.youtube.com/watch?v=Clvfkm6azDs',
5364
},
54-
] as const;
65+
};
66+
67+
export const trainingVideos: readonly TrainingVideo[] = Object.entries(trainingVideosById).map(
68+
([id, video]) => ({ id, ...video }),
69+
);

‎apps/portal/src/app/api/portal/complete-training/route.test.ts‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
1616
completionFindMany: vi.fn(),
1717
logger: vi.fn(),
1818
fetch: vi.fn(),
19+
afterTasks: [] as Array<() => unknown>,
1920
env: {
2021
SERVICE_TOKEN_PORTAL: undefined as string | undefined,
2122
NEXT_PUBLIC_API_URL: 'http://api.test',
@@ -45,6 +46,25 @@ vi.mock('@/env.mjs', () => ({ env: mocks.env }));
4546
vi.mock('@/utils/logger', () => ({ logger: mocks.logger }));
4647
vi.stubGlobal('fetch', mocks.fetch);
4748

49+
// The route defers the completion email with `after` so it runs once the
50+
// response has been sent. Capture the deferred task instead of running it, so
51+
// tests can prove the response does NOT wait on the email and then drive the
52+
// email path explicitly.
53+
vi.mock('next/server', async (importOriginal) => {
54+
const actual = await importOriginal<typeof import('next/server')>();
55+
return {
56+
...actual,
57+
after: (task: () => unknown) => {
58+
mocks.afterTasks.push(task);
59+
},
60+
};
61+
});
62+
63+
/** Runs whatever the route deferred, mimicking Next flushing `after` tasks. */
64+
async function flushAfterTasks(): Promise<void> {
65+
await Promise.all(mocks.afterTasks.splice(0).map((task) => task()));
66+
}
67+
4868
import { POST } from './route';
4969

5070
function makeRequest(body: unknown): NextRequest {
@@ -67,6 +87,7 @@ function makeRawRequest(raw: string): NextRequest {
6787
describe('POST /api/portal/complete-training', () => {
6888
beforeEach(() => {
6989
vi.clearAllMocks();
90+
mocks.afterTasks.length = 0;
7091
mocks.env.SERVICE_TOKEN_PORTAL = undefined;
7192
});
7293

@@ -203,9 +224,7 @@ describe('POST /api/portal/complete-training', () => {
203224
it('rejects an unknown video ID', async () => {
204225
mocks.getSession.mockResolvedValue({ user: { id: 'user_1' } });
205226

206-
const res = await POST(
207-
makeRequest({ videoId: 'not-a-real-video', organizationId: 'org_1' }),
208-
);
227+
const res = await POST(makeRequest({ videoId: 'not-a-real-video', organizationId: 'org_1' }));
209228

210229
expect(res.status).toBe(400);
211230
expect(mocks.memberFindFirst).not.toHaveBeenCalled();
@@ -227,9 +246,7 @@ describe('POST /api/portal/complete-training', () => {
227246
// No HIPAA framework instance for this org.
228247
mocks.frameworkInstanceFindFirst.mockResolvedValue(null);
229248

230-
const res = await POST(
231-
makeRequest({ videoId: 'hipaa-sat-1', organizationId: 'org_1' }),
232-
);
249+
const res = await POST(makeRequest({ videoId: 'hipaa-sat-1', organizationId: 'org_1' }));
233250

234251
expect(res.status).toBe(400);
235252
expect(mocks.frameworkInstanceFindFirst).toHaveBeenCalledWith({
@@ -257,9 +274,7 @@ describe('POST /api/portal/complete-training', () => {
257274
};
258275
mocks.completionUpsert.mockResolvedValue(record);
259276

260-
const res = await POST(
261-
makeRequest({ videoId: 'hipaa-sat-1', organizationId: 'org_1' }),
262-
);
277+
const res = await POST(makeRequest({ videoId: 'hipaa-sat-1', organizationId: 'org_1' }));
263278

264279
expect(res.status).toBe(200);
265280
expect(mocks.completionUpsert).toHaveBeenCalledWith({
@@ -300,15 +315,24 @@ describe('POST /api/portal/complete-training', () => {
300315
// dev-only `logger` util would have swallowed it outside development).
301316
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
302317

303-
const res = await POST(
304-
makeRequest({ videoId: 'hipaa-sat-1', organizationId: 'org_1' }),
305-
);
318+
const res = await POST(makeRequest({ videoId: 'hipaa-sat-1', organizationId: 'org_1' }));
306319

307320
// Completion is persisted regardless — the email is best-effort.
308321
expect(res.status).toBe(200);
322+
// ...and the response did not wait on it: the API call that renders the
323+
// certificate and sends the mail only happens once `after` is flushed.
324+
expect(mocks.fetch).not.toHaveBeenCalled();
325+
326+
await flushAfterTasks();
327+
309328
expect(mocks.fetch).toHaveBeenCalledWith(
310329
'http://api.test/v1/training/send-hipaa-completion-email',
311-
expect.objectContaining({ method: 'POST' }),
330+
// The request is bounded: Node's fetch otherwise waits ~5 minutes, which
331+
// would keep the invocation alive long after the response was sent.
332+
expect.objectContaining({
333+
method: 'POST',
334+
signal: expect.any(AbortSignal),
335+
}),
312336
);
313337
// The HTTP failure must surface (in prod too), not be swallowed as success.
314338
expect(errorSpy).toHaveBeenCalledWith(

0 commit comments

Comments
 (0)