Skip to content

fix(training): defer completion email and share canonical training IDs - #3529

Merged
tofikwest merged 2 commits into
mainfrom
fix/training-completion-shared-source
Jul 28, 2026
Merged

fix(training): defer completion email and share canonical training IDs#3529
tofikwest merged 2 commits into
mainfrom
fix/training-completion-shared-source

Conversation

@tofikwest

@tofikwest tofikwest commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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 sendCompletionEmailIfComplete before returning. That call hits POST /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's fetch also 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.

  • Deferred with after() from next/server, so it runs once the response has been sent.
  • Bounded the request with 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.markVideoComplete owns. That was the fifth copy — the same constants were already hardcoded in training.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 as evidence-forms):

  • GENERAL_TRAINING_VIDEO_IDS, HIPAA_TRAINING_ID, isTrainingVideoId
  • HIPAA_FRAMEWORK_NAME, HIPAA_TRAINING_UNAVAILABLE_MESSAGE, hipaaFrameworkInstanceWhere — so both completion paths reject ineligible orgs identically

The rendered video lists now satisfies the 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: swapping sat-5 for sat-6 fails typecheck).

Also

Fixed training-hipaa.spec.ts, which was failing on main — its @db mock was never given frameworkInstance when 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 --noEmit clean (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)
  • Prettier clean on all changed 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/company so 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

    • Deferred the completion email with after() and bounded the request with AbortSignal.timeout(30_000). Tests confirm the response doesn’t wait and the request uses an AbortSignal.
    • Unified HIPAA eligibility and error copy via shared helpers (hipaaFrameworkInstanceWhere, HIPAA_TRAINING_UNAVAILABLE_MESSAGE).
    • Fixed training-hipaa.spec.ts by mocking frameworkInstance and adding the rejection case for ineligible orgs.
  • Refactors

    • Centralized IDs and helpers in @trycompai/company (GENERAL_TRAINING_VIDEO_IDS, HIPAA_TRAINING_ID, isTrainingVideoId, HIPAA_FRAMEWORK_NAME, HIPAA_TRAINING_UNAVAILABLE_MESSAGE, hipaaFrameworkInstanceWhere), and updated the portal route, Nest TrainingService, and frameworks-people-score.helper.ts to use them.
    • Made the rendered video lists exhaustive both ways by keying them as 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.

Review in cubic

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app Ready Ready Preview, Comment Jul 28, 2026 9:53pm
comp-framework-editor Ready Ready Preview, Comment Jul 28, 2026 9:53pm
portal Ready Ready Preview, Comment Jul 28, 2026 9:53pm

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 11 files

Confidence score: 5/5

  • apps/api/src/training/training.service.ts exceeding 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

Comment thread apps/app/src/lib/data/training-videos.ts Outdated
HIPAA_TRAINING_UNAVAILABLE_MESSAGE,
hipaaFrameworkInstanceWhere,
isTrainingVideoId,
} from '@trycompai/company';

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

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.
…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.
@tofikwest

Copy link
Copy Markdown
Contributor Author

Addressed both findings.

P2 (exhaustiveness) — valid, fixed. You're right, and it was the dangerous direction: hasCompletedAllTraining requires a row for every canonical ID, so a canonical ID with no rendered video leaves employees permanently unable to finish training.

Note the suggested Exclude<GeneralTrainingVideoId, (typeof trainingVideos)[number]['id']> would not have caught it here — the readonly TrainingVideo[] annotation widens id to string, so the Exclude resolves to never and the guard passes vacuously.

Keyed the data by the canonical union instead — Record<GeneralTrainingVideoId, Omit<TrainingVideo, 'id'>> is exhaustive by construction. Verified by introducing each drift and compiling:

  • canonical gains sat-6, UI doesn't render it → TS2741: Property '"sat-6"' is missing ... but required in type 'Record<...>'
  • UI renders sat-6, canonical doesn't have it → TS2353: ''sat-6'' does not exist in type 'Record<"sat-1" | ... | "sat-5", ...>'

P3 (300-line limit) — not introduced by this PR. training.service.ts is 309 lines on main and 310 here; the violation predates the change by one line (git show origin/main:apps/api/src/training/training.service.ts | wc -l). Extracting certificate/email resolution means new DI wiring and test surface, which doesn't belong in a PR about deferring an email — better as its own change.

Verification: bun run --filter '@trycompai/portal' build and --filter '@trycompai/app' build both exit 0; portal vitest 14/14; api training + people-score specs 20/20; portal tsc 0 errors; app tsc at its pre-existing baseline of 15 (all in unrelated test files).

@tofikwest
tofikwest merged commit fecb556 into main Jul 28, 2026
11 checks passed
@tofikwest
tofikwest deleted the fix/training-completion-shared-source branch July 28, 2026 21:57
claudfuen pushed a commit that referenced this pull request Jul 29, 2026
# [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))
@claudfuen

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.111.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants