Skip to main content

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_TRAINING permission.

User-Facing Flow​

  1. An admin creates a training with a title and optional description.
  2. The admin adds content:
    • PDF
    • PPT
    • Video
  3. The admin assigns the training:
    • To individual users
    • To users with selected roles
    • To the whole organization
  4. The system stores resolved user-level assignment rows.
  5. Users open Training, view assigned items, and resume progress.
  6. Progress updates are stored per content item.
  7. When all content is complete, the training assignment is completed.
  8. A PDF certificate is generated and stored.
  9. 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:

  1. Every page has been on screen for at least TrainingContent.minSecondsPerPage (default 5, set by the admin per PDF).
  2. 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-lib when 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 as 0, 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 and pageCount, never removes stored pages, and caps the total at timeSpentSeconds / minSecondsPerPage, so a client can't claim pages it had no time to view. Pages are stored in TrainingContentProgress.viewedPages.
  • progressPercent is the lower of the time ratio and the pages ratio, capped at 99 until complete.
  • POST .../content/:contentId/complete no 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 allow GET from 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.
  • COMPLETED never 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:

  • PENDING
  • IN_PROGRESS
  • COMPLETED
  • OVERDUE

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.

EmailWhen
Training assignedRight after the assignment is created
ReminderAbout 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_SECRET protected) 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_notices before 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:

  1. The backend marks the assignment COMPLETED.
  2. It generates a basic PDF certificate.
  3. It uploads the certificate through StorageService.
  4. 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.assigned
  • training.progress_updated
  • training.content_completed
  • training.completed
  • training.certificate_issued
  • training.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.ts
  • apps/backend/src/modules/training/training/training.service.ts
  • apps/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:

  1. Uploaded PDF/PPT files are stored in R2.
  2. Certificate PDFs are stored in R2.
  3. The database stores the object key.
  4. The backend returns signed read URLs only after access checks.

Video Flow​

Video content supports two paths.

When R2 and Mux are configured:

  1. Admin uploads a video file from Training Admin.
  2. The backend stores it privately in R2.
  3. The backend creates a temporary signed R2 URL.
  4. The backend asks Mux to create an asset from that signed URL.
  5. The backend stores the Mux asset ID and playback ID.
  6. 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 with fetch, 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.
[
{
"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_TRAINING
  • MANAGE_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_ADMIN
  • COMPLIANCE_ANALYST
  • MSP_SUPER_ADMIN
  • MSP_USER
  • ORG_USER_EMPLOYEE gets learner access.

Database Tables​

Core tables:

  • trainings
  • training_contents
  • training_assignment_batches
  • training_assignments
  • training_content_progress

Core enums:

  • TrainingContentType
  • TrainingAssignmentScope
  • TrainingAssignmentStatus
  • TrainingContentProgressStatus

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​

  1. Finalizing a PPT upload sets TrainingContent.conversionStatus = PENDING and starts the conversion in the background (Vercel waitUntil), so the admin doesn't wait.
  2. The backend sends Gotenberg a short-lived signed R2 link (downloadFrom). Gotenberg downloads the PPT itself, converts it and returns the PDF.
  3. The backend stores the PDF under the same ${teamId}/training prefix (convertedStorageKey), counts its pages (pageCount) and sets READY.
  4. The learner detail response includes pdfUrl (signed) for a READY PPT; 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​

Hostgotenberg.vodazok.com (DNS only, not proxied by Cloudflare)
SSHssh deploy@<server IP> (root login disabled)
OSUbuntu 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
TLSCaddy reverse proxy with Let's Encrypt, 300s upstream timeouts
Size4 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) and sudo journalctl -u caddy -f (proxy). Each request carries Gotenberg-Trace: training-content-<id>. Backend logs: Converted content <id> or Conversion of content <id> failed.
  • Rotate the password: change GOTENBERG_API_BASIC_AUTH_PASSWORD in /opt/gotenberg/.env, sudo docker compose up -d, then update GOTENBERG_PASSWORD in 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 root docker-compose.yml to 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 .pptx can 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-utils has 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.com and not only localhost.

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_USERNAME and GOTENBERG_PASSWORD are set on the API project.
  • CRON_SECRET is set (the cron picks up anything the upload-time run missed).
  • Hover the Conversion failed chip for the reason. HTTP 401 means the credentials are wrong; HTTP 403 means the source URL isn't on the download allow-list; TimeoutError means 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.