Skip to content

Commit 335a918

Browse files
committed
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.
1 parent 0d79093 commit 335a918

11 files changed

Lines changed: 201 additions & 57 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';

‎apps/app/src/lib/data/training-videos.ts‎

Lines changed: 12 additions & 1 deletion
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,6 +17,15 @@ export interface TrainingVideo {
1517
url: string;
1618
}
1719

20+
/**
21+
* A rendered video whose ID is one the training-completion paths actually
22+
* accept. `@trycompai/company` owns the canonical ID list, shared with the
23+
* NestJS training service and the portal's complete-training route.
24+
*/
25+
type CanonicalTrainingVideo = Omit<TrainingVideo, 'id'> & {
26+
id: GeneralTrainingVideoId;
27+
};
28+
1829
export const trainingVideos: readonly TrainingVideo[] = [
1930
{
2031
id: 'sat-1',
@@ -51,4 +62,4 @@ export const trainingVideos: readonly TrainingVideo[] = [
5162
youtubeId: 'Clvfkm6azDs',
5263
url: 'https://www.youtube.com/watch?v=Clvfkm6azDs',
5364
},
54-
] as const;
65+
] as const satisfies readonly CanonicalTrainingVideo[];

‎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(

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

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
import { auth } from '@/app/lib/auth';
22
import { env } from '@/env.mjs';
3-
import { HIPAA_TRAINING_ID } from '@/lib/data/hipaa-training-content';
4-
import { trainingVideos } from '@/lib/data/training-videos';
53
import { db } from '@db/server';
6-
import { type NextRequest, NextResponse } from 'next/server';
4+
import {
5+
GENERAL_TRAINING_VIDEO_IDS,
6+
HIPAA_TRAINING_ID,
7+
HIPAA_TRAINING_UNAVAILABLE_MESSAGE,
8+
hipaaFrameworkInstanceWhere,
9+
isTrainingVideoId,
10+
} from '@trycompai/company';
11+
import { after, type NextRequest, NextResponse } from 'next/server';
712
import { z } from 'zod';
813

914
export const runtime = 'nodejs';
1015
export const dynamic = 'force-dynamic';
1116

12-
// Canonical training video IDs, derived from the same source the UI renders.
13-
const GENERAL_TRAINING_IDS = trainingVideos.map((v) => v.id);
14-
const VALID_VIDEO_IDS = new Set<string>([
15-
...GENERAL_TRAINING_IDS,
16-
HIPAA_TRAINING_ID,
17-
]);
17+
// The completion email hits the NestJS API, which resolves the member, renders
18+
// a PDF certificate and calls the email provider — seconds of work. It runs
19+
// after the response (see `after` below), but still needs a bound: Node's fetch
20+
// waits ~5 minutes by default, which would pin the serverless invocation open
21+
// long after the employee has moved on.
22+
const COMPLETION_EMAIL_TIMEOUT_MS = 30_000;
1823

1924
const schema = z.object({
2025
videoId: z.string().min(1),
@@ -58,7 +63,7 @@ export async function POST(req: NextRequest) {
5863

5964
const { videoId, organizationId } = parsed.data;
6065

61-
if (!VALID_VIDEO_IDS.has(videoId)) {
66+
if (!isTrainingVideoId(videoId)) {
6267
return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 });
6368
}
6469

@@ -76,20 +81,17 @@ export async function POST(req: NextRequest) {
7681
}
7782

7883
// HIPAA training is only available to orgs that have the HIPAA framework
79-
// enabled. Mirror the NestJS training service (markVideoComplete) so this
80-
// route can't create HIPAA completion records — and trigger HIPAA
81-
// certificate artifacts — for orgs the service would reject, which would
82-
// desync the two completion paths.
84+
// enabled. The eligibility rule is shared with the NestJS training service
85+
// (markVideoComplete) so this route can't create HIPAA completion records —
86+
// and trigger HIPAA certificate artifacts — for orgs the service would
87+
// reject, which would desync the two completion paths.
8388
if (videoId === HIPAA_TRAINING_ID) {
8489
const hipaaInstance = await db.frameworkInstance.findFirst({
85-
where: { organizationId, framework: { name: 'HIPAA' } },
90+
where: hipaaFrameworkInstanceWhere(organizationId),
8691
select: { id: true },
8792
});
8893
if (!hipaaInstance) {
89-
return NextResponse.json(
90-
{ error: 'HIPAA training is not available for this organization' },
91-
{ status: 400 },
92-
);
94+
return NextResponse.json({ error: HIPAA_TRAINING_UNAVAILABLE_MESSAGE }, { status: 400 });
9395
}
9496
}
9597

@@ -116,11 +118,19 @@ export async function POST(req: NextRequest) {
116118
// Best-effort: trigger the completion certificate email once the relevant
117119
// training is fully done. Reuses the NestJS email pipeline via the portal
118120
// service token, so we don't duplicate the certificate/email logic here.
119-
await sendCompletionEmailIfComplete({
120-
videoId,
121-
memberId: member.id,
122-
organizationId,
123-
});
121+
//
122+
// Deferred with `after` so it runs once the response has been sent. The
123+
// completion is already durable at this point, and the API call behind it
124+
// renders a PDF certificate and talks to the email provider — awaiting that
125+
// made the employee's click wait seconds on the happy path, and time out on
126+
// the unhappy one, for work whose outcome they never see.
127+
after(() =>
128+
sendCompletionEmailIfComplete({
129+
videoId,
130+
memberId: member.id,
131+
organizationId,
132+
}),
133+
);
124134

125135
return NextResponse.json({ success: true, data: record });
126136
}
@@ -154,13 +164,13 @@ async function sendCompletionEmailIfComplete({
154164
const completed = await db.employeeTrainingVideoCompletion.findMany({
155165
where: {
156166
memberId,
157-
videoId: { in: GENERAL_TRAINING_IDS },
167+
videoId: { in: [...GENERAL_TRAINING_VIDEO_IDS] },
158168
completedAt: { not: null },
159169
},
160170
select: { id: true },
161171
});
162172

163-
if (completed.length === GENERAL_TRAINING_IDS.length) {
173+
if (completed.length === GENERAL_TRAINING_VIDEO_IDS.length) {
164174
await triggerCompletionEmail({
165175
url: `${apiUrl}/v1/training/send-completion-email`,
166176
serviceToken,
@@ -199,6 +209,7 @@ async function triggerCompletionEmail({
199209
'x-organization-id': organizationId,
200210
},
201211
body: JSON.stringify({ memberId }),
212+
signal: AbortSignal.timeout(COMPLETION_EMAIL_TIMEOUT_MS),
202213
});
203214

204215
// fetch only rejects on network errors, never on a 4xx/5xx response. Without

‎apps/portal/src/lib/data/hipaa-training-content.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
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';
29

310
export interface HipaaTrainingSection {
411
title: string;
@@ -59,7 +66,7 @@ Prompt reporting matters even when you are unsure whether PHI was actually expos
5966
] as const;
6067

6168
export const hipaaAcknowledgements: readonly string[] = [
62-
'I completed the organization\'s general security awareness training and this HIPAA Security Awareness Training Add-On.',
69+
"I completed the organization's general security awareness training and this HIPAA Security Awareness Training Add-On.",
6370
'I understand that PHI includes information in electronic, paper, image, audio, and verbal form.',
6471
'I will access, use, disclose, transmit, and store PHI only as authorized for my job responsibilities and in accordance with organization policy.',
6572
'I will use approved safeguards, including strong authentication, secure handling practices, and prompt reporting of suspicious activity or incidents.',

0 commit comments

Comments
 (0)