Files

34 KiB
Raw Permalink Blame History

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 COMPLIANCE course 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 COMPLIANCE to the COURSE_TYPE PostgreSQL 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 dueDate for cycle 1 is set by the course admin/teacher when learners are assigned or enrolled into a compliance course
  • dueDate is 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 = validUntil from 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 retakeIntervalMonths months
  • validUntil = completedAt + retakeIntervalMonths
  • validUntil is the date that triggers renewal into the next cycle
  • The system tracks this validity window per learner per course in the course_completion_record table
  • 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 validUntil is reached, the system creates the next cycle as the new active record with status not_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_started and a new dueDate
  • 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 passingScore on 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 passingScore and maxRetakeAttempts is not reached, the learner may retry

CCT-9: Auto-grading Behavior

  • Like SELF_PACED courses, COMPLIANCE courses 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, and became_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 expiresAt date equal to the completion's validUntil
  • 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 associated cycleNumber
  • 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:

  1. Run the cron runner directly — bun run apps/cron/index.ts as a separate long-running process
  2. Use system cron — add two crontab entries that curl the 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
    
  3. 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

  1. Should converting an existing SELF_PACED course to COMPLIANCE retroactively create completion records for current enrollees?
  2. When a waiver expires, should the learner automatically get a new cycle, or return to their previous status?
  3. Should compliance courses support a "pre-assessment" that lets learners who already know the material skip directly to the final assessment?
  4. Do we need org-level defaults for compliance settings (e.g., "all compliance courses in this org default to 12-month retake")?