fix(training): defer completion email and share canonical training IDs - #3529
Conversation
There was a problem hiding this comment.
1 issue found across 11 files
Confidence score: 5/5
apps/api/src/training/training.service.tsexceeding the 300-line limit raises maintainability risk by concentrating too many responsibilities, which can make future changes harder to review and increase the chance of regressions in this service—extract a focused unit (such as certificate/email resolution) to keep the import-driven addition isolated.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/api/src/training/training.service.ts">
<violation number="1" location="apps/api/src/training/training.service.ts:14">
P3: `TrainingService` now exceeds the API's 300-line file limit (310 lines). Extract a focused responsibility (for example certificate/email resolution) before adding this import-driven functionality.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| HIPAA_TRAINING_UNAVAILABLE_MESSAGE, | ||
| hipaaFrameworkInstanceWhere, | ||
| isTrainingVideoId, | ||
| } from '@trycompai/company'; |
There was a problem hiding this comment.
P3: TrainingService now exceeds the API's 300-line file limit (310 lines). Extract a focused responsibility (for example certificate/email resolution) before adding this import-driven functionality.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/training/training.service.ts, line 14:
<comment>`TrainingService` now exceeds the API's 300-line file limit (310 lines). Extract a focused responsibility (for example certificate/email resolution) before adding this import-driven functionality.</comment>
<file context>
@@ -5,13 +5,16 @@ import {
+ HIPAA_TRAINING_UNAVAILABLE_MESSAGE,
+ hipaaFrameworkInstanceWhere,
+ isTrainingVideoId,
+} from '@trycompai/company';
import { TrainingEmailService } from './training-email.service';
import { TrainingCertificatePdfService } from './training-certificate-pdf.service';
</file context>
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.
3b96202 to
335a918
Compare
…h 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.
|
Addressed both findings. P2 (exhaustiveness) — valid, fixed. You're right, and it was the dangerous direction: Note the suggested Keyed the data by the canonical union instead —
P3 (300-line limit) — not introduced by this PR. Verification: |
# [3.111.0](v3.110.1...v3.111.0) (2026-07-29) ### Bug Fixes * **api:** classify the take-over method on an unclear outcome too ([539fdba](539fdba)) * **api:** only classify a switchable passkey when a code method exists ([#3528](#3528)) ([0d79093](0d79093)) * **app:** make a half-finished connect resumable, not a forced full-screen step ([#3525](#3525)) ([b1afcae](b1afcae)) * **cloud-security:** add missing logGroupName to CreateLogGroup remediation ([#3515](#3515)) ([76ae56c](76ae56c)) * harden sign-in classification + align take-over messaging ([#3527](#3527)) ([914cb1e](914cb1e)) * **integrations:** wrap aws add account form in dialog and scroll into view ([#3526](#3526)) ([07e91ae](07e91ae)), closes [#418](#418) * make the 2FA take-over universal — tailor guidance to what the page asks for ([#3520](#3520)) ([b12f3fe](b12f3fe)) * **policies:** remove archived policies from framework controls after unlinking ([#3513](#3513)) ([d639df2](d639df2)) * **training:** defer completion email and share canonical training IDs ([#3529](#3529)) ([fecb556](fecb556)) * **training:** remove rbac gate from mark-complete endpoint ([#3501](#3501)) ([8c5e98f](8c5e98f)), closes [#3455](#3455) ### Features * connection longevity + one-click 'Make permanent' 2FA ([#3524](#3524)) ([7345e3c](7345e3c)) * **policies:** add bulk upload for policy migration ([#3514](#3514)) ([1940f06](1940f06)) * **security-questionnaire:** add browser extension ([#3064](#3064)) ([e678421](e678421))
|
🎉 This PR is included in version 3.111.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Follow-up to #3501, addressing both cubic review findings on
apps/portal/src/app/api/portal/complete-training/route.ts.1. The "best-effort" email was blocking the response
The route awaited
sendCompletionEmailIfCompletebefore returning. That call hitsPOST /v1/training/send-{hipaa-,}completion-email, which resolves the member, renders a PDF certificate and calls the email provider — so every final-video completion made the employee wait seconds for work whose outcome they never see. Node'sfetchalso has no default request timeout, so a slow or unreachable API could stall the response for minutes even though the completion row was already committed.after()fromnext/server, so it runs once the response has been sent.AbortSignal.timeout(30s)so a hung API can't pin the invocation open.2. Video IDs and HIPAA eligibility were duplicated
The route restated the valid-video-ID list and the HIPAA framework gate that
TrainingService.markVideoCompleteowns. That was the fifth copy — the same constants were already hardcoded intraining.service.ts,frameworks-people-score.helper.ts, and the two apps'training-videos.ts/hipaa-training-content.ts.Moved them to
@trycompai/company, the existing shared-definitions package that api, app and portal all already consume (same pattern asevidence-forms):GENERAL_TRAINING_VIDEO_IDS,HIPAA_TRAINING_ID,isTrainingVideoIdHIPAA_FRAMEWORK_NAME,HIPAA_TRAINING_UNAVAILABLE_MESSAGE,hipaaFrameworkInstanceWhere— so both completion paths reject ineligible orgs identicallyThe rendered video lists now
satisfiesthe canonical ID union, so adding a video to the UI without adding it to the shared list is a compile error rather than a silent desync (verified: swappingsat-5forsat-6fails typecheck).Also
Fixed
training-hipaa.spec.ts, which was failing onmain— its@dbmock was never givenframeworkInstancewhen the HIPAA gate was added. Added coverage for the gate's rejection path, mirroring the portal route's test.Verification
apps/portal— 14/14 vitest pass;tsc --noEmitclean (0 errors)apps/api— training + frameworks-people-score specs 20/20 pass; no new type errors (22 pre-existing, all in unrelated modules)apps/app— no new type errors (15 pre-existing, all in unrelated test files)Summary by cubic
Defer the completion email until after the response (with a 30s timeout) and centralize training IDs and HIPAA eligibility in
@trycompai/companyso the API and portal share the same rules. Key the app and portal video lists by the canonical IDs to make them exhaustive both ways and type-safe.Bug Fixes
after()and bounded the request withAbortSignal.timeout(30_000). Tests confirm the response doesn’t wait and the request uses anAbortSignal.hipaaFrameworkInstanceWhere,HIPAA_TRAINING_UNAVAILABLE_MESSAGE).training-hipaa.spec.tsby mockingframeworkInstanceand adding the rejection case for ineligible orgs.Refactors
@trycompai/company(GENERAL_TRAINING_VIDEO_IDS,HIPAA_TRAINING_ID,isTrainingVideoId,HIPAA_FRAMEWORK_NAME,HIPAA_TRAINING_UNAVAILABLE_MESSAGE,hipaaFrameworkInstanceWhere), and updated the portal route, NestTrainingService, andframeworks-people-score.helper.tsto use them.Record<GeneralTrainingVideoId, ...>; the HIPAA ID stays a local literal tied by type-only link to the canonical union to avoid runtime deps while preventing drift.Written for commit 5cb8566. Summary will update on new commits.