Compliance Course Type PRD
Status
- Done (shipped)
Date
- February 21, 2026 (updated April 11, 2026)
Purpose
Add a COMPLIANCE course type to ClassroomIO for courses that must be retaken after a given period. This targets organizations in regulated industries (healthcare, finance, manufacturing, government) that need recurring training with automated renewal, reminders, and compliance status tracking.
Scope: This PRD covers the
COMPLIANCEcourse type only — retake intervals, completion validity, automatic re-enrollment, reminders, certificates, and dashboards. Programs already exist in the app and are out of scope. Policy attestation, audit trails, and HRIS integrations are separate efforts.
1. Executive Summary
The Problem
Companies in regulated industries need courses that employees must retake periodically. Today, ClassroomIO courses are one-and-done — there's no way to say "this HIPAA course must be retaken every 12 months" and have the system enforce it.
Target Customers
| Industry | Example Use Cases |
|---|---|
| Healthcare | HIPAA training, patient safety, infection control |
| Financial Services | Anti-money laundering, fraud prevention, data privacy |
| Manufacturing | OSHA safety, quality control (ISO), hazardous materials |
| Government | Security clearance, ethics training, procurement rules |
| Education | FERPA, Title IX, campus safety |
| Retail/Hospitality | Food safety, anti-harassment, PCI-DSS |
Solution
A third course type — COMPLIANCE — that carries retake settings directly on the course. When a learner completes it, their completion is valid for N months. When it expires, they're automatically re-enrolled. The system sends reminders before deadlines and tracks compliance status per learner.
2. Current State
What Already Exists
| Feature | Current State |
|---|---|
| Course Types | LIVE_CLASS and SELF_PACED — no recurring/retake support |
| Programs | Already implemented — can group courses together |
| Assessments | Exercises with auto-grading for self-paced courses |
| Certificates | PDF generation with themes, thresholds, and deadlines |
| User Management | Invite students, track progress per course |
What's Missing
| Gap | Impact |
|---|---|
No COMPLIANCE course type |
Can't model courses that need periodic retaking |
| No completion validity window | Completions are permanent — no expiry |
| No automatic re-enrollment | Admins must manually re-invite learners |
| No compliance status tracking | No way to see who is compliant vs overdue |
| No retake reminders | Manual follow-up is unsustainable at scale |
3. Feature Requirements
3.1 COMPLIANCE Course Type
Purpose
Add COMPLIANCE to the COURSE_TYPE enum. A compliance course behaves like a self-paced course for content delivery but carries retake settings that drive automatic re-enrollment and compliance tracking.
Requirements
CCT-1: Course Type Enum Extension
- Add
COMPLIANCEto theCOURSE_TYPEPostgreSQL enum:['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE'] - Compliance courses behave like self-paced courses for content delivery (lessons, exercises, grading) but carry additional retake/renewal configuration
- All existing course features (content editor, assessments, certificates, invites) work unchanged
CCT-2: Compliance Settings (per course)
When a course is created or updated with type COMPLIANCE, the following settings are available via a new compliance JSONB column:
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
retakeIntervalMonths |
integer | Yes | — | Months after completion before the course must be retaken. Common: 3, 6, 12, 24 |
gracePeriodDays |
integer | — | 0 |
Additional days after the retake deadline before marking non-compliant |
reminderDaysBefore |
integer[] | — | [30, 7, 1] |
Days before retake deadline to send reminder notifications |
isMandatory |
boolean | — | true |
Whether the course is mandatory for all assigned learners |
framework |
string | — | null |
Regulatory framework tag: HIPAA, OSHA, SOX, GDPR, PCI_DSS, FERPA, ISO, CUSTOM |
maxRetakeAttempts |
integer | null | — | null |
Max attempts per retake cycle. null = unlimited |
passingScore |
integer | — | 80 |
Minimum score (0–100) to count as a valid completion |
CCT-3: Due Date Ownership
- The initial
dueDatefor cycle 1 is set by the course admin/teacher when learners are assigned or enrolled into a compliance course dueDateis the operational deadline used for reminder scheduling, grace period handling, extensions, and overdue status- For renewal cycles, the system automatically creates the next cycle with
dueDate = validUntilfrom the previous compliant cycle - Admin actions such as extend or reset may override the current cycle's
dueDate
CCT-4: Completion Validity Window
- When a learner completes a compliance course, their completion is valid for
retakeIntervalMonthsmonths validUntil=completedAt+retakeIntervalMonthsvalidUntilis the date that triggers renewal into the next cycle- The system tracks this validity window per learner per course in the
course_completion_recordtable - A valid completion satisfies compliance; an expired completion does not
CCT-5: Retake Lifecycle A learner's relationship to a compliance course follows this cycle:
Cycle 1 created → Not Started → In Progress → Completed (Compliant)
↓
[validUntil reaches current date]
↓
Cycle 2 created automatically as new active cycle
↓
Not Started → Expiring Soon → In Grace Period → Non-Compliant → Completed
- There is exactly one active cycle per learner per compliance course
- The active cycle owns the learner's current
status,dueDate, reminder schedule, and overdue state - When a learner completes a cycle, that cycle becomes historical and remains visible in history
- When
validUntilis reached, the system creates the next cycle as the new active record with statusnot_started - The previous cycle is preserved for audit/history and is no longer used as the source of current reminder or overdue state
CCT-6: Compliance Status per Learner
Each learner enrolled in a compliance course has one of these statuses (tracked on course_completion_record):
| Status | Condition |
|---|---|
not_started |
Enrolled but hasn't begun |
in_progress |
Started but not yet completed/passed |
compliant |
Completed within validity window |
expiring_soon |
Within the earliest reminder window (e.g., 30 days before expiry) |
in_grace_period |
Past expiry date but within grace period |
non_compliant |
Past expiry + grace period with no valid completion |
waived |
Exempted by admin (with reason and optional expiration) |
CCT-7: Automatic Re-enrollment
- When a learner's completion validity expires, the system automatically creates a new completion record with status
not_startedand a newdueDate - Previous completions are preserved in history (never deleted)
- The learner sees the course reappear in their "Due" list
- A background job (
compliance-expiry-checker) runs daily to process expirations
CCT-8: Completion Rule
- Compliance completion is determined by the course completion rule, not by any single passing submission in isolation
- For v1, a compliance cycle is complete when the learner satisfies the course's configured completion requirements and achieves
passingScoreon the designated final assessment for the cycle - Submission and lesson events may trigger a re-check, but a centralized compliance completion service decides whether the cycle becomes
compliant - If below
passingScoreandmaxRetakeAttemptsis not reached, the learner may retry
CCT-9: Auto-grading Behavior
- Like
SELF_PACEDcourses,COMPLIANCEcourses auto-grade submissions when all questions are auto-gradable - Auto-graded results feed into the centralized compliance completion evaluation
- Manually graded assessments may also complete the cycle once grading is finished and the completion rule is satisfied
3.2 Notifications & Reminders
Purpose
Automatically remind learners before their compliance course deadlines and notify admins about overdue learners.
Requirements
NR-1: Learner Reminders
- Send reminders at each interval defined in
reminderDaysBefore(e.g., 30, 7, 1 days before due) - Email includes: course name, due date, current progress, direct link to course
- In-app notification badge on the course card
NR-2: Admin Alerts
- Notify course admins when learners become overdue (transition to
non_compliant) - Daily digest option: summary of all overdue learners across compliance courses in the org
NR-3: Completion Confirmation
- Email + in-app notification when a learner completes a compliance course
- Include: course name, score, "compliant until" date, certificate download link (if applicable)
NR-4: Renewal Notification
- When a new retake cycle is created (automatic re-enrollment), notify the learner
- Email includes: course name, new due date, link to start
NR-5: Notification Idempotency
- Reminder and alert delivery is persisted per cycle so scheduled jobs can be re-run safely
- The system records reminder events such as
reminder_30d,reminder_7d,reminder_1d,renewal_created, andbecame_non_compliant - A given reminder or alert may be sent at most once per cycle per channel unless an admin explicitly triggers a manual resend
Note
: The notification system PRD (
prd/notification-system) covers the general delivery infrastructure (templates, channels, preferences). This section only defines the compliance-specific triggers.
3.3 Certificate Lifecycle for Compliance Courses
Purpose
Certificates issued for compliance courses should carry an expiration date matching the completion validity window, and be re-issued on each retake cycle.
Requirements
CL-1: Certificate Expiration
- When a compliance course has certificates enabled, the issued certificate includes an
expiresAtdate equal to the completion'svalidUntil - Certificate status follows the completion record: valid → expiring soon → expired
CL-2: Certificate Re-issuance
- Each successful retake cycle issues a new certificate
- Previous certificates are preserved in history with status
renewed - Learner can view/download all historical certificates
CL-3: Certificate Display
- Certificate PDF shows: completion date, expiration date, cycle number, framework tag (if set)
- Certificate list view in dashboard shows validity status badge (valid/expiring/expired)
CL-4: Certificate Persistence
- Compliance certificates are stored per cycle in a dedicated issuance table linked to
course_completion_record - Each issuance stores
issuedAt,expiresAt,status, and the associatedcycleNumber - The existing course-level certificate settings remain the configuration source, but per-cycle issuance history is persisted separately so learners and admins can view all historical certificates
3.4 Compliance Dashboard
Purpose
Give course admins visibility into who is compliant, who is expiring, and who is overdue — per compliance course.
Requirements
CD-1: Course-Level Compliance Overview
Available on the course management page for any COMPLIANCE type course:
- Summary stats: total enrolled, compliant, expiring soon, in grace period, non-compliant, waived
- Compliance rate percentage (compliant / total)
- Learner table with: name, status, due date, last completed date, score, cycle number
CD-2: Status Filters & Sorting
- Filter learners by compliance status
- Sort by: due date (soonest first), status severity, name
- Search by learner name or email
CD-3: Bulk Admin Actions
- Bulk waive selected learners (with reason and optional expiry)
- Bulk extend due dates
- Bulk force re-enrollment (reset cycle)
- Export learner compliance data as CSV
CD-4: Org-Level Compliance Summary On the organization dashboard, show a summary card for compliance courses:
- Count of compliance courses in the org
- Overall compliance rate across all compliance courses
- Number of overdue learners (link to drill down)
4. User Experience Flows
4.1 Admin: Creating a Compliance Course
1. Click "New Course" → Select type: "Compliance"
↓
2. Fill in title, description (same as any course)
↓
3. Compliance settings panel appears:
- Retake interval: [12] months (dropdown: 3, 6, 12, 18, 24, custom)
- Framework: [HIPAA] (optional dropdown)
- Passing score: [80]%
- Grace period: [14] days
- Reminders: [30, 7, 1] days before due
↓
4. Add content: lessons, exercises, assessments (same as self-paced)
↓
5. Configure certificate (optional, same as today but will auto-expire)
↓
6. Publish course → Invite/enroll learners
4.2 Student: Taking a Compliance Course (First Time)
1. Receive invite / see course in dashboard
- Course card shows: compliance badge, framework tag, "Due by [date]"
↓
2. Open course → See banner: "Required: Complete by [date]"
↓
3. Work through lessons and exercises (same as self-paced)
↓
4. Complete final assessment
- Score >= passing score → "Compliant until [date]" confirmation
- Score < passing score → "You need [X]% to pass. [N] attempts remaining."
↓
5. Receive confirmation email + certificate (if enabled)
↓
6. Course card updates: green "Compliant" badge, "Valid until [date]"
4.3 Student: The Renewal Cycle
1. [30 days before expiry] Receive reminder email:
"Your HIPAA Compliance certification expires on [date]. Retake the course to stay compliant."
- Course card turns yellow: "Expiring Soon"
↓
2. [7 days before] Second reminder
↓
3. [1 day before] Final reminder
↓
4. [Expiry date] If not completed:
- Course card turns orange: "In Grace Period" (if grace period > 0)
- OR red: "Non-Compliant" (if no grace period)
↓
5. Student clicks the same course → Sees the active cycle plus prior cycle history:
- This is not a separate course and not a course-switcher in nav
- The learner stays in the same course shell and sees a "Current Cycle" panel plus a "Past Cycles" history list
"Cycle 1: Completed Jan 2025 (Score: 92%) — Expired"
"Cycle 2: Due by Jan 2026 — [Start Retake]"
↓
6. Completes retake → New certificate issued → Back to "Compliant"
4.4 Admin: Monitoring Compliance
1. Open compliance course → "Compliance" tab in course management
↓
2. See dashboard: 85% compliant, 10% expiring soon, 5% overdue
↓
3. Filter by "Non-Compliant" → See list of overdue learners
↓
4. Select overdue learners → Actions:
- "Send Reminder" (manual nudge)
- "Extend Due Date" (grant extension)
- "Waive" (exempt with reason)
↓
5. Export compliance report as CSV for records
5. Database Schema
Enum Change
ALTER TYPE "COURSE_TYPE" ADD VALUE 'COMPLIANCE';
New Column on course Table
ALTER TABLE course ADD COLUMN compliance JSONB DEFAULT NULL;
-- Only populated when type = 'COMPLIANCE'. Example:
-- {
-- "retakeIntervalMonths": 12,
-- "gracePeriodDays": 14,
-- "reminderDaysBefore": [30, 14, 7, 1],
-- "isMandatory": true,
-- "framework": "HIPAA",
-- "maxRetakeAttempts": null,
-- "passingScore": 80
-- }
New Table: course_completion_record
CREATE TABLE course_completion_record (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE,
group_member_id UUID NOT NULL REFERENCES groupmember(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES profile(id),
-- Cycle tracking
cycle_number INTEGER NOT NULL DEFAULT 1, -- 1 = first time, 2 = first retake, etc.
status VARCHAR NOT NULL DEFAULT 'not_started',
-- 'not_started', 'in_progress', 'compliant', 'expiring_soon',
-- 'in_grace_period', 'non_compliant', 'waived'
-- Dates
due_date TIMESTAMPTZ NOT NULL,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
valid_until TIMESTAMPTZ, -- completedAt + retakeIntervalMonths
expired_at TIMESTAMPTZ, -- when status changed to non_compliant
-- Results
score INTEGER, -- best score for this cycle
attempts INTEGER DEFAULT 0,
time_spent_minutes INTEGER DEFAULT 0,
-- Waiver
waived_by UUID REFERENCES profile(id),
waiver_reason TEXT,
waiver_expires_at TIMESTAMPTZ,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(course_id, profile_id, cycle_number)
);
CREATE INDEX idx_ccr_course ON course_completion_record(course_id);
CREATE INDEX idx_ccr_profile ON course_completion_record(profile_id);
CREATE INDEX idx_ccr_status ON course_completion_record(status);
CREATE INDEX idx_ccr_due_date ON course_completion_record(due_date);
CREATE INDEX idx_ccr_valid_until ON course_completion_record(valid_until);
New Table: course_completion_notification_event
CREATE TABLE course_completion_notification_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_completion_record_id UUID NOT NULL REFERENCES course_completion_record(id) ON DELETE CASCADE,
channel VARCHAR NOT NULL, -- 'email', 'in_app'
event_type VARCHAR NOT NULL, -- 'reminder_30d', 'renewal_created', 'became_non_compliant', etc.
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(course_completion_record_id, channel, event_type)
);
New Table: course_certificate_issue
CREATE TABLE course_certificate_issue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
course_completion_record_id UUID NOT NULL REFERENCES course_completion_record(id) ON DELETE CASCADE,
cycle_number INTEGER NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
status VARCHAR NOT NULL DEFAULT 'valid', -- 'valid', 'expiring_soon', 'expired', 'renewed'
file_url TEXT,
UNIQUE(course_completion_record_id)
);
Drizzle Schema (TypeScript)
// In packages/db/src/schema.ts
// Update enum
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']);
// Add to course table definition
compliance: jsonb().$type<{
retakeIntervalMonths: number;
gracePeriodDays?: number;
reminderDaysBefore?: number[];
isMandatory?: boolean;
framework?: 'HIPAA' | 'OSHA' | 'SOX' | 'GDPR' | 'PCI_DSS' | 'FERPA' | 'ISO' | 'CUSTOM' | null;
maxRetakeAttempts?: number | null;
passingScore?: number;
}>(),
// New table
export const courseCompletionRecord = pgTable(
'course_completion_record',
{
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
courseId: uuid('course_id').notNull(),
groupMemberId: uuid('group_member_id').notNull(),
profileId: uuid('profile_id').notNull(),
cycleNumber: integer('cycle_number').default(1).notNull(),
status: varchar().default('not_started').notNull(),
dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }).notNull(),
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
validUntil: timestamp('valid_until', { withTimezone: true, mode: 'string' }),
expiredAt: timestamp('expired_at', { withTimezone: true, mode: 'string' }),
score: integer(),
attempts: integer().default(0),
timeSpentMinutes: integer('time_spent_minutes').default(0),
waivedBy: uuid('waived_by'),
waiverReason: text('waiver_reason'),
waiverExpiresAt: timestamp('waiver_expires_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
},
(table) => [
foreignKey({ columns: [table.courseId], foreignColumns: [course.id], name: 'ccr_course_id_fkey' }),
foreignKey({ columns: [table.groupMemberId], foreignColumns: [groupmember.id], name: 'ccr_group_member_id_fkey' }),
foreignKey({ columns: [table.profileId], foreignColumns: [profile.id], name: 'ccr_profile_id_fkey' }),
unique('ccr_unique_cycle').on(table.courseId, table.profileId, table.cycleNumber),
index('idx_ccr_course').on(table.courseId),
index('idx_ccr_profile').on(table.profileId),
index('idx_ccr_status').on(table.status),
index('idx_ccr_due_date').on(table.dueDate),
index('idx_ccr_valid_until').on(table.validUntil),
]
);
Validation Schema (Zod)
// In packages/utils/src/validation/course/course.ts
export const ZComplianceSettings = z.object({
retakeIntervalMonths: z.number().int().min(1).max(120),
gracePeriodDays: z.number().int().min(0).max(365).optional().default(0),
reminderDaysBefore: z.array(z.number().int().min(1).max(365)).max(10).optional().default([30, 7, 1]),
isMandatory: z.boolean().optional().default(true),
framework: z.enum(['HIPAA', 'OSHA', 'SOX', 'GDPR', 'PCI_DSS', 'FERPA', 'ISO', 'CUSTOM']).nullable().optional(),
maxRetakeAttempts: z.number().int().min(1).nullable().optional(),
passingScore: z.number().int().min(0).max(100).optional().default(80),
});
// Update ZCourseCreate
export const ZCourseCreate = z.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']),
organizationId: z.string().min(1),
compliance: ZComplianceSettings.optional(),
}).refine(
(data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined,
{ message: 'Compliance settings are required for COMPLIANCE courses', path: ['compliance'] }
);
// Update ZCourseUpdate
export const ZCourseUpdate = z.object({
// ... existing fields ...
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']).optional(),
compliance: ZComplianceSettings.optional(),
});
6. API Endpoints
Course Endpoints (extended)
POST /courses # Now accepts type: 'COMPLIANCE' + compliance settings
PATCH /courses/:courseId # Can update compliance settings
Compliance-Specific Endpoints
GET /courses/:courseId/compliance
→ Compliance overview: stats + learner list with statuses
GET /courses/:courseId/compliance/learners/:profileId
→ Learner compliance history: current cycle + all previous cycles
POST /courses/:courseId/compliance/waive
Body: { profileId, reason, expiresAt? }
→ Waive a learner from the current cycle
POST /courses/:courseId/compliance/reset
Body: { profileId, newDueDate }
→ Force re-enrollment (create new cycle)
POST /courses/:courseId/compliance/extend
Body: { profileIds[], newDueDate }
→ Bulk extend due dates
GET /courses/:courseId/compliance/export
→ CSV export of all learner compliance records
7. Background Job Infrastructure
Current State
There is no cron or job queue system in ClassroomIO today. Redis exists but is only used for rate limiting and caching. The compliance course type requires two daily jobs — this is the first scheduled work in the platform.
Architecture: Internal API Routes + Cron Runner
The compliance logic lives inside the existing API (where it has DB access, services, and context). A separate lightweight Bun cron runner triggers it on a schedule by calling internal endpoints.
┌─────────────────┐ POST /internal/compliance/* ┌──────────────┐
│ Cron Runner │ ──────────────────────────────────► │ API │
│ (apps/cron) │ x-api-key: PRIVATE_SERVER_KEY │ (apps/api) │
│ Bun + croner │ │ Hono │
└─────────────────┘ └──────┬───────┘
│
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
Internal API Routes
Two new routes under /internal/, protected by the existing PRIVATE_SERVER_KEY middleware (apps/api/src/middlewares/api-key.ts):
POST /internal/compliance/check-expiry
→ Checks valid_until dates across all compliance courses
→ Creates the next cycle when a compliant cycle reaches valid_until
→ Transitions only the active cycle through: not_started/in_progress → expiring_soon → in_grace_period → non_compliant
→ Returns: { processed: number, transitioned: number, reEnrolled: number }
POST /internal/compliance/send-reminders
→ Finds compliance courses with upcoming due dates
→ Sends reminders based on each course's reminderDaysBefore settings
→ Sends admin notifications for newly overdue learners
→ Returns: { remindersSent: number, adminAlertsSent: number }
Cron Runner (apps/cron/)
Minimal Bun project. Its only job is to call the internal API endpoints on a schedule.
// apps/cron/index.ts
import { Cron } from 'croner';
const API_URL = process.env.API_URL;
const SERVER_KEY = process.env.PRIVATE_SERVER_KEY;
const headers = { 'x-api-key': SERVER_KEY, 'content-type': 'application/json' };
// Daily at 2:00 AM UTC — check expirations and transition statuses
new Cron('0 2 * * *', async () => {
const res = await fetch(`${API_URL}/internal/compliance/check-expiry`, { method: 'POST', headers });
console.log('check-expiry:', await res.json());
});
// Daily at 2:30 AM UTC — send reminders (runs after expiry check so statuses are up to date)
new Cron('30 2 * * *', async () => {
const res = await fetch(`${API_URL}/internal/compliance/send-reminders`, { method: 'POST', headers });
console.log('send-reminders:', await res.json());
});
console.log('Cron runner started');
Dependencies: Just croner (lightweight, zero-dep cron library for Bun/Node).
Security
| Concern | How it's handled |
|---|---|
| Authentication | PRIVATE_SERVER_KEY — the existing server-to-server key, validated by the api-key middleware. No new auth mechanism needed |
| Route exposure | /internal/* prefix is never registered in public-facing route groups. Middleware rejects requests without a valid key |
| Network isolation | On Railway, the cron runner and API are separate services in the same project — internal communication stays within Railway's private network |
| Idempotency | Both jobs are safe to run multiple times. check-expiry only transitions records that match date conditions. send-reminders tracks sent reminders to avoid duplicates |
Deployment
Railway (production): The cron runner is a separate Railway service in the same project as the API. It needs two env vars:
API_URL— internal URL of the API service (Railway provides this via service references)PRIVATE_SERVER_KEY— same value as the API's key
Self-hosted: Not included in docker-compose. A README.md in apps/cron/ explains the three options:
- Run the cron runner directly —
bun run apps/cron/index.tsas a separate long-running process - Use system cron — add two crontab entries that
curlthe internal endpoints:0 2 * * * curl -X POST -H "x-api-key: $PRIVATE_SERVER_KEY" http://localhost:3002/internal/compliance/check-expiry 30 2 * * * curl -X POST -H "x-api-key: $PRIVATE_SERVER_KEY" http://localhost:3002/internal/compliance/send-reminders - Manual — call the endpoints on demand, skip automation entirely
Adding Future Jobs
When new scheduled work is needed beyond compliance, add a new /internal/* route in the API and a new Cron(...) call in the runner. The pattern scales without architectural changes.
8. Technical Architecture
Frontend Changes
apps/dashboard/src/lib/features/course/
components/
new-course-modal.svelte # Updated: COMPLIANCE option alongside LIVE_CLASS/SELF_PACED
compliance-settings-form.svelte # New: retake interval, framework, grace period, passing score
compliance-status-badge.svelte # New: compliant/expiring/non-compliant badges
compliance-learner-table.svelte # New: learner list with compliance statuses + bulk actions
pages/
settings.svelte # Updated: compliance settings section when type=COMPLIANCE
compliance.svelte # New: compliance tab in course management
apps/course-app/ (learner-facing)
# Updated: show compliance status badge, due date, retake history on course page
# Updated: course card shows "Due by" and color-coded status
# Updated: same course page shows "Current Cycle" and "Past Cycles" history instead of separate course entries
Files That Need Code Changes
| File | Change |
|---|---|
packages/db/src/schema.ts |
Add COMPLIANCE to enum, compliance column to course, new courseCompletionRecord table |
packages/utils/src/validation/course/course.ts |
Add ZComplianceSettings, update ZCourseCreate/ZCourseUpdate with COMPLIANCE and .refine() |
packages/utils/src/validation/course-import/course-import.ts |
Add COMPLIANCE to ZCourseImportDraftCourse.type |
apps/api/src/services/course/course.ts |
Handle compliance field in create/update |
apps/api/src/services/submission/submission.ts |
Record valid completion on course_completion_record when score >= passingScore |
apps/api/src/routes/v1/courses.ts |
Add /courses/:courseId/compliance/* endpoints |
apps/api/src/routes/internal/compliance.ts |
New: /internal/compliance/check-expiry and send-reminders routes |
apps/cron/index.ts |
New: Bun cron runner that calls internal endpoints on a schedule |
apps/dashboard/.../new-course-modal.svelte |
Add COMPLIANCE option to course type selector |
apps/dashboard/.../settings.svelte |
Show compliance settings panel for COMPLIANCE courses |
9. Implementation Roadmap
Phase 1: Schema & API (Week 1-2)
| Task | Duration | Deliverable |
|---|---|---|
Extend COURSE_TYPE enum + migration |
1 day | COMPLIANCE in Postgres enum and Drizzle schema |
Add compliance JSONB column to course |
1 day | Migration + schema update |
course_completion_record table + Drizzle model |
2 days | New table with indexes |
ZComplianceSettings Zod validation |
1 day | Validation for create/update |
| Update course create/update API | 2 days | Accept compliance settings |
| Compliance-specific API endpoints | 2 days | Overview, waive, reset, extend, export |
Phase 2: Completion Tracking & Background Jobs (Week 2-3)
| Task | Duration | Deliverable |
|---|---|---|
| Record compliance completion on submission | 2 days | Submission service integration |
/internal/compliance/* routes + PRIVATE_SERVER_KEY guard |
1 day | Internal API endpoints |
check-expiry service logic |
2 days | Daily status transitions + auto re-enrollment |
send-reminders service logic |
1 day | Reminder emails based on reminderDaysBefore |
apps/cron Bun project + croner setup |
1 day | Cron runner calling internal endpoints |
| Deploy cron runner as Railway service | 0.5 day | Running in production |
| Admin overdue notifications | 1 day | Notify admins of newly non-compliant learners |
Phase 3: Dashboard & Admin UI (Week 3-4)
| Task | Duration | Deliverable |
|---|---|---|
| COMPLIANCE option in course creation modal | 1 day | Course type selector update |
| Compliance settings form component | 2 days | Retake interval, framework, grace period UI |
| Compliance settings in course settings page | 1 day | Edit compliance config after creation |
| Compliance tab in course management | 3 days | Learner table with statuses, bulk actions |
| Org-level compliance summary card | 1 day | Dashboard overview widget |
Phase 4: Student Experience (Week 4-5)
| Task | Duration | Deliverable |
|---|---|---|
| Compliance badge + due date on course card | 1 day | Learner dashboard update |
| "Required: Complete by" banner on course page | 1 day | Course page update |
| Retake history view | 2 days | "Cycle 1: Completed... Cycle 2: Due..." |
| "Compliant until" confirmation on completion | 1 day | Post-completion UI |
| Certificate with expiration date | 1 day | Certificate PDF update |
Milestone: Organizations can create compliance courses with retake periods. Learners see due dates, get reminders, and are automatically re-enrolled when completions expire. Admins can monitor compliance status and take bulk actions.
10. Success Metrics
| Metric | Target | How to Measure |
|---|---|---|
| Time to create a compliance course | < 5 min | User testing |
| Compliance course completion rate | > 90% | course_completion_record data |
| Reminder email → completion conversion | > 50% | Track completions within 7 days of reminder |
| Overdue rate (non-compliant after grace period) | < 10% | course_completion_record status counts |
| Admin time spent on manual follow-up | Reduced by 80% | Customer interviews |
11. Open Questions
- Should converting an existing
SELF_PACEDcourse toCOMPLIANCEretroactively create completion records for current enrollees? - When a waiver expires, should the learner automatically get a new cycle, or return to their previous status?
- Should compliance courses support a "pre-assessment" that lets learners who already know the material skip directly to the final assessment?
- Do we need org-level defaults for compliance settings (e.g., "all compliance courses in this org default to 12-month retake")?