HIPAA Training Module
This document explains how the HIPAA Training module works end to end, what is configurable, and what needs to be provided before using real file and video infrastructure.
Scope
The training module is primarily an organization feature.
- It is available only inside ORG workspaces.
- MSP users can use it only after switching into an accessible ORG workspace.
- MSP workspaces do not show Training or Training Admin navigation.
- Regular ORG users can see assigned training.
- Training administration requires the
MANAGE_TRAININGpermission.
User-Facing Flow
- An admin creates a training with a title and optional description.
- The admin adds content:
- PPT
- Video
- The admin assigns the training:
- To individual users
- To users with selected roles
- To the whole organization
- The system stores resolved user-level assignment rows.
- Users open
Training, view assigned items, and resume progress. - Progress updates are stored per content item.
- When all content is complete, the training assignment is completed.
- A PDF certificate is generated and stored.
- Admins can view dashboard counts and export training audit logs as CSV.
Admin UX
Route:
/w/:slug/training-admin
Available actions:
- Create training.
- Add PDF/PPT/video content.
- Assign training.
- View metrics:
- Assigned
- Completed
- In Progress
- Overdue
- Export training audit logs as CSV.
The Training Admin route is ORG-only and permission-gated.
Learner UX
Routes:
/w/:slug/training
/w/:slug/training/:assignmentId
Learners can:
- View assigned training.
- Open a training assignment.
- Watch video content.
- View PDF/PPT content.
- Resume from stored video position.
- Complete PDF/PPT content by viewing it (no manual "mark complete").
- Download a certificate after completion.
Access is assignment-based. A user can only open assignments where assignment.userId matches the current user and assignment.teamId matches the active ORG workspace.
Assignment Behavior
Assignments are resolved at assignment time.
Individual Users
The admin selects users directly. The backend verifies those users belong to the active ORG team.
Roles
The admin selects one or more role names. The backend finds users who currently have those roles in the active ORG team.
This is a snapshot. Future users added to that role are not automatically assigned.
Entire Organization
The backend finds all current users in the active ORG team and creates assignment rows for each user.
This is also a snapshot. Future users are not automatically assigned.
Progress Rules
Progress is tracked per assignment and per content item.
Stored fields include:
- Last position, for video resume.
- Progress percentage.
- Content progress status.
- Completed timestamp.
Video
Default completion threshold is 80%.
The threshold is stored per content item so it can be changed per training content later.
Video progress is updated from the player using current time divided by video duration.
PDF
PDFs open in the training player's own page-by-page viewer (react-pdf, built on pdf.js), not a browser iframe, so the player knows which page is on screen. A PDF completes when both are true:
- Every page has been on screen for at least
TrainingContent.minSecondsPerPage(default 5, set by the admin per PDF). - Total active viewing time reaches
TrainingContent.minViewSeconds(set by the admin, in minutes, when adding the step).
How it's enforced:
- The page count is computed server-side with
pdf-libwhen the PDF is added (TrainingContent.pageCount). PDFs uploaded before this existed are counted the first time a learner opens them. If the file can't be parsed the count is stored as0, and that PDF falls back to time-only completion. - Time only counts while the tab is visible and the learner is active. After 60s with no mouse, key, scroll or touch input the timer pauses until they interact again.
- Each heartbeat sends the time delta (same clamping as below), the full list of pages seen, and the current page (
lastPosition, used to resume). The server keeps only pages between 1 andpageCount, never removes stored pages, and caps the total attimeSpentSeconds / minSecondsPerPage, so a client can't claim pages it had no time to view. Pages are stored inTrainingContentProgress.viewedPages. progressPercentis the lower of the time ratio and the pages ratio, capped at 99 until complete.POST .../content/:contentId/completeno longer completes PDF/PPT items. Documents complete only through tracked heartbeats.- The viewer downloads the PDF with
fetch, so the R2 bucket's CORS policy must allowGETfrom every app origin (including tenant subdomains), not just the upload origins.
PPT
PowerPoints are converted to PDF after upload so learners get the same page viewer and tracking as PDFs: each slide is a page, and the step completes once every slide was on screen for Seconds per slide (minSecondsPerPage) and the total reaches Minimum time (minViewSeconds). See "PowerPoint Conversion" below for how conversion runs.
Until a PPT's conversion is ready (or when it failed, or conversion isn't configured), the learner gets the Microsoft Office viewer and the step completes on dwell time alone. While the step is open and the browser tab is visible, the player sends a heartbeat every ~15s with the seconds elapsed since the previous one (also flushed when the tab is hidden or the learner switches step). The server adds each delta to TrainingContentProgress.timeSpentSeconds and marks the item COMPLETED once the total reaches minViewSeconds. Until then progressPercent is derived from timeSpentSeconds / minViewSeconds, capped at 99.
- Deltas are clamped to 30s per request and to the wall-clock time since the row was last written, so replayed or multi-tab heartbeats can't add time faster than real time.
COMPLETEDnever regresses on later updates (this applies to video too).- Converted slides are still images of each slide: animations, transitions and embedded video are lost. The upload form tells admins this.
- If a learner is part-way through a PPT when its conversion finishes, their next open uses the slide viewer and they need to view the slides. Anything already completed stays completed.
- Admins see total time viewed per learner in the assignment batch detail.
Assignment Status Rules
Assignments can be:
PENDINGIN_PROGRESSCOMPLETEDOVERDUE
Status changes:
- No progress:
PENDING - Some progress:
IN_PROGRESS - All content completed:
COMPLETED - Due date passed and not complete:
OVERDUE
Due date is required when creating an assignment. Admins pick a date and a time (default 11:59 PM, in the admin's own timezone); the deadline is that exact moment.
Email Notifications
Assignees are emailed automatically. There is no opt-out.
| When | |
|---|---|
| Training assigned | Right after the assignment is created |
| Reminder | About 3 days, 1 day and 4 hours before the deadline |
- Reminders stop once the assignment is completed, the user leaves the team, or the user is deactivated.
- Reminders whose window had already started when the training was assigned are skipped. For example, training assigned 2 days before its deadline gets the assignment email, then the 1-day and 4-hour reminders, but no 3-day reminder.
- Only one reminder is sent at a time. If a cron run is missed, the next run sends the most urgent reminder that applies; it doesn't send the missed ones too.
- Moving the deadline re-arms the reminders for the new date.
- A Vercel Cron job (
GET /api/v1/internal/training-reminders, every 15 minutes,CRON_SECRETprotected) sends them, so a reminder can be up to 15 minutes late. The emails say "about" for that reason. Deadlines in emails are shown in New York time. - Each email is recorded in
training_assignment_noticesbefore sending, so it goes out at most once. If delivery fails, the record is removed and the next run retries.
Certificate Flow
When all content items are completed:
- The backend marks the assignment
COMPLETED. - It generates a basic PDF certificate.
- It uploads the certificate through
StorageService. - It stores:
- Certificate URL
- Certificate storage key
- Certificate issued timestamp
The certificate contains:
- Learner name
- Organization name
- Training title
- Issued timestamp
- Certificate ID
Audit Flow
Training events are written through the existing audit log system.
Logged events:
training.assignedtraining.progress_updatedtraining.content_completedtraining.completedtraining.certificate_issuedtraining.audit_exported
Admins can export training audit logs as CSV from Training Admin.
Storage Flow
Storage is handled by StorageService.
Relevant files:
apps/backend/src/modules/storage/storage.service.tsapps/backend/src/modules/training/training/training.service.tsapps/backend/src/modules/training/certificate/certificate.service.ts
Local/Fallback Mode
If Cloudflare R2 env vars are missing, the service uses a placeholder mode.
This keeps local development from failing, but generated URLs point to storage.example.com and are not usable for real downloads.
R2 Mode
When R2 env vars are configured:
- Uploaded PDF/PPT files are stored in R2.
- Certificate PDFs are stored in R2.
- The database stores the object key.
- The backend returns signed read URLs only after access checks.
Video Flow
Video content supports two paths.
Recommended: Upload Video File
When R2 and Mux are configured:
- Admin uploads a video file from Training Admin.
- The backend stores it privately in R2.
- The backend creates a temporary signed R2 URL.
- The backend asks Mux to create an asset from that signed URL.
- The backend stores the Mux asset ID and playback ID.
- The learner player streams from Mux.
Manual: Existing Mux Playback ID
If a video already exists in Mux, the admin can provide the playback ID manually.
This does not require uploading a video file through RiskFlow.
Required Environment Variables
These are required for normal backend operation and already existed before this module:
DATABASE_URL=
JWT_SECRET=
JWT_EXPIRES_IN=15m
FRONTEND_URL=
PORT=3000
COOKIE_DOMAIN=
PLATFORM_TEAM_SLUG=riskflow
Email variables:
RESEND_API_KEY=
RESEND_FROM_EMAIL=
EMAIL_DELIVERY_REQUIRED=false
R2 Configuration
Add these for real file storage:
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_SIGNED_URL_TTL_SECONDS=900
What You Need From Cloudflare
From your Cloudflare account:
- R2 account ID.
- R2 bucket name.
- R2 API token or access key pair with object read/write permissions.
- A CORS policy on the bucket (see Cloudflare R2 CORS) with two rules:
- Uploads (
PUT) from the app origins only. - Reads (
GET,HEAD) from any origin. The PDF viewer downloads files withfetch, and tenants live on their own subdomains, so a fixed list breaks every tenant not on it. The signed URL is the access control, so*is safe here.
- Uploads (
[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["Content-Length", "Content-Range", "Accept-Ranges", "ETag"],
"MaxAgeSeconds": 3600
},
{
"AllowedOrigins": ["<existing upload origins>"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
Important:
- Keep the bucket private; the backend issues presigned URLs for uploads (PUT) and reads (GET).
- Do not rely on a public bucket URL for HIPAA-sensitive files.
Mux Configuration
Add these for real video processing:
MUX_TOKEN_ID=
MUX_TOKEN_SECRET=
What You Need From Mux
From your Mux account:
- Access token ID.
- Access token secret.
- Permission to create video assets.
Video playback uses public Mux playback IDs. The source upload URL from R2 is temporary and signed.
Configurable Values
Completion Threshold
Default:
80%
Stored per TrainingContent as completionThreshold.
Currently configurable from the backend DTO, but the admin UI does not yet expose a threshold field.
Due Date
Required during assignment: a date plus a time (default 11:59 PM).
Used to move assignments to OVERDUE and to schedule reminder emails.
Assignment Scope
Configurable per assignment:
- User
- Role snapshot
- Org-wide snapshot
R2 Signed URL TTL
Configured by:
R2_SIGNED_URL_TTL_SECONDS=900
Default is 900 seconds.
Permissions
Shared permissions added:
VIEW_TRAININGMANAGE_TRAINING
Current behavior:
- Learner-side Training navigation is visible for ORG workspace members.
- Training Admin requires
MANAGE_TRAINING. - Backend admin endpoints require
MANAGE_TRAINING. - Learner endpoints enforce assignment ownership and ORG workspace checks.
Seeded roles include training permissions for:
ORG_ADMINCOMPLIANCE_ANALYSTMSP_SUPER_ADMINMSP_USERORG_USER_EMPLOYEEgets learner access.
Database Tables
Core tables:
trainingstraining_contentstraining_assignment_batchestraining_assignmentstraining_content_progress
Core enums:
TrainingContentTypeTrainingAssignmentScopeTrainingAssignmentStatusTrainingContentProgressStatus
Migration:
apps/backend/prisma/migrations/0003_training_module/migration.sql
Important Files
Backend:
apps/backend/src/modules/training/training.module.ts
apps/backend/src/modules/training/training/training.controller.ts
apps/backend/src/modules/training/training/training.service.ts
apps/backend/src/modules/training/training/training.repository.ts
apps/backend/src/modules/training/assignment/assignment.controller.ts
apps/backend/src/modules/training/assignment/assignment.service.ts
apps/backend/src/modules/training/assignment/assignment.resolver.ts
apps/backend/src/modules/training/progress/progress.controller.ts
apps/backend/src/modules/training/progress/progress.service.ts
apps/backend/src/modules/training/certificate/certificate.service.ts
apps/backend/src/modules/training/audit/training-audit.service.ts
apps/backend/src/modules/training/video/mux.service.ts
apps/backend/src/modules/training/conversion/ppt-conversion.service.ts
apps/backend/src/modules/training/conversion/gotenberg.client.ts
apps/backend/src/modules/storage/storage.service.ts
Frontend:
apps/frontend/src/pages/Training/AssignedTraining.tsx
apps/frontend/src/pages/Training/TrainingPlayer.tsx
apps/frontend/src/pages/Training/pdf/TrainingPdfStep.tsx
apps/frontend/src/pages/TrainingAdmin/TrainingAdmin.tsx
apps/frontend/src/pages/TrainingAdmin/StepList.tsx
apps/frontend/src/services/training.service.ts
apps/frontend/src/routes/AppRoutes.tsx
apps/frontend/src/lib/constants.ts
Permissions:
packages/permissions-utils/src/types.ts
packages/permissions-utils/src/constants.ts
Seed:
apps/backend/prisma/seed.ts
What Is Needed From Your End
For local development:
- Run database reset or push after schema changes.
- Reseed if you want seeded users to include training permissions.
- Restart backend and frontend dev servers after shared package changes.
Recommended local commands:
pnpm --filter @riskflow/permissions-utils build
pnpm --filter @riskflow/backend db:reset
pnpm --filter @riskflow/backend dev
pnpm --filter frontend dev
For real storage and video:
- Create/configure a private Cloudflare R2 bucket.
- Add R2 env vars.
- Create Mux API credentials.
- Add Mux env vars.
- Restart backend after adding env vars.
- Test PDF upload.
- Test video upload.
- Test certificate download.
For production:
- Use strong
JWT_SECRET. - Use production cookie domain.
- Ensure CORS allows uploads from the production app domains and reads from any origin (see R2 Configuration).
- Set the Gotenberg env vars (see PowerPoint Conversion).
- Keep R2 bucket private.
- Rotate R2 and Mux credentials according to your security policy.
- Confirm HIPAA/BAA coverage with Cloudflare and Mux before storing PHI. Training decks are converted on our own Gotenberg server.
PowerPoint Conversion
PPTs are converted to PDF with Gotenberg (LibreOffice in a container), self-hosted at gotenberg.vodazok.com so client files stay on infrastructure we control.
How It Works
- Finalizing a PPT upload sets
TrainingContent.conversionStatus = PENDINGand starts the conversion in the background (VercelwaitUntil), so the admin doesn't wait. - The backend sends Gotenberg a short-lived signed R2 link (
downloadFrom). Gotenberg downloads the PPT itself, converts it and returns the PDF. - The backend stores the PDF under the same
${teamId}/trainingprefix (convertedStorageKey), counts its pages (pageCount) and setsREADY. - The learner detail response includes
pdfUrl(signed) for aREADYPPT; the player shows that in the page viewer.
Safety net: the cron GET /api/v1/internal/training-conversions (every 15 minutes, CRON_SECRET) converts anything still PENDING, a few at a time. It also picks up runs that died (a claim older than 10 minutes) and, via the migration, every PPT uploaded before conversion existed.
Failures: each PPT gets 3 attempts (one per cron run after the first). A file Gotenberg rejects as unreadable fails at once. Failed PPTs show Conversion failed with a Retry button in the admin step list (POST /api/v1/training/:id/content/:contentId/conversion/retry, MANAGE_TRAINING, team-scoped).
Kill switch: unset GOTENBERG_URL and redeploy. New PPTs aren't converted and nothing is retried; learners get the Office viewer for anything not already converted.
Environment Variables (backend)
# https://gotenberg.vodazok.com (local: http://localhost:3100)
GOTENBERG_URL=
# Basic auth for the server. Unset locally.
GOTENBERG_USERNAME=
GOTENBERG_PASSWORD=
# Optional, default 240000; must stay under the function maxDuration (300s)
GOTENBERG_TIMEOUT_MS=
Local Development
docker compose up -d gotenberg # http://localhost:3100, same image as production
Set GOTENBERG_URL=http://localhost:3100 in apps/backend/.env. No credentials are needed locally.
Server
| Host | gotenberg.vodazok.com (DNS only, not proxied by Cloudflare) |
| SSH | ssh deploy@<server IP> (root login disabled) |
| OS | Ubuntu 24.04; UFW allows 22/80/443; fail2ban; unattended security upgrades |
| Stack | /opt/gotenberg/ (Docker Compose): gotenberg/gotenberg:8.37.0 on 127.0.0.1:3000 |
| TLS | Caddy reverse proxy with Let's Encrypt, 300s upstream timeouts |
| Size | 4 CPU / 7.8 GiB; container capped at 2 CPUs / 1800 MB |
Gotenberg enforces basic auth itself (API_ENABLE_BASIC_AUTH); the password lives only in /opt/gotenberg/.env (mode 600) and in Vercel. /health is public. Container settings: API_TIMEOUT=230s, WEBHOOK_DISABLE=true, and API_DOWNLOAD_FROM_ALLOW_LIST so it only fetches R2 links.
LibreOffice converts one file at a time and queues the rest, so keep concurrency low: the cron sends 3 at a time.
Smoke Test
curl -s -o /dev/null -w "%{http_code}\n" https://gotenberg.vodazok.com/health # 200
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
https://gotenberg.vodazok.com/forms/libreoffice/convert # 401
curl -s -o out.pdf -w "%{http_code}\n" -u "riskflow:$GOTENBERG_PASSWORD" \
-F [email protected] https://gotenberg.vodazok.com/forms/libreoffice/convert # 200 + PDF
Operations
- Logs:
cd /opt/gotenberg && sudo docker compose logs -f(Gotenberg) andsudo journalctl -u caddy -f(proxy). Each request carriesGotenberg-Trace: training-content-<id>. Backend logs:Converted content <id>orConversion of content <id> failed. - Rotate the password: change
GOTENBERG_API_BASIC_AUTH_PASSWORDin/opt/gotenberg/.env,sudo docker compose up -d, then updateGOTENBERG_PASSWORDin Vercel and redeploy. - Upgrade Gotenberg: bump the exact image tag in
/opt/gotenberg/docker-compose.yml,sudo docker compose pull && sudo docker compose up -d; update the rootdocker-compose.ymlto match. - Check the queue:
SELECT conversion_status, count(*) FROM training_contents WHERE type = 'PPT' GROUP BY 1;
Known Gaps And Future Improvements
The current module is intentionally lean. These items are not yet implemented:
- SCORM support.
- Quizzes.
- Automatic re-training cycles.
- Dynamic role assignment updates for future users.
- Admin preview of a converted PPT still shows the original in the Office viewer, not the PDF learners see.
- A corrupt
.pptxcan still "convert": LibreOffice turns unreadable bytes into pages of junk text, and the step shows "Slides ready". - Settings (
minViewSeconds,minSecondsPerPage) can't be edited on existing content. - Real-time analytics.
- Admin editing/deleting existing training content.
- Rich certificate template designer.
- PDF audit export.
Troubleshooting
Training Admin Does Not Show
Check:
- Active workspace is an ORG workspace.
- User has
MANAGE_TRAINING. - Seed has been rerun after permission changes.
@riskflow/permissions-utilshas been rebuilt.- Frontend dev server has been restarted.
Logout Does Not Clear Cookies
Check:
- Browser host matches
COOKIE_DOMAIN. - Backend has been restarted after auth changes.
- Cookies are scoped to
.riskflow.comand not onlylocalhost.
Video Upload Fails
Check:
- R2 env vars are set.
- Mux env vars are set.
- R2 object is accessible through a signed URL.
- Mux credentials can create video assets.
PPT Stuck On "Converting To Slides" Or "Conversion Failed"
Check:
GOTENBERG_URL,GOTENBERG_USERNAMEandGOTENBERG_PASSWORDare set on the API project.CRON_SECRETis set (the cron picks up anything the upload-time run missed).- Hover the Conversion failed chip for the reason.
HTTP 401means the credentials are wrong;HTTP 403means the source URL isn't on the download allow-list;TimeoutErrormeans the deck is too large or slow to convert. - Gotenberg logs on the server for the matching
training-content-<id>trace.
PDF/PPT Download Or Preview Fails
Check:
- R2 env vars are set.
- Object exists in the bucket.
- Signed URL TTL has not expired.
- The assignment belongs to the current user.