chore: ai, public course and other ui improvements
This commit is contained in:
@@ -0,0 +1,755 @@
|
||||
# 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
|
||||
```sql
|
||||
ALTER TYPE "COURSE_TYPE" ADD VALUE 'COMPLIANCE';
|
||||
```
|
||||
|
||||
### New Column on `course` Table
|
||||
```sql
|
||||
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`
|
||||
```sql
|
||||
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`
|
||||
```sql
|
||||
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`
|
||||
```sql
|
||||
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)
|
||||
|
||||
```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)
|
||||
|
||||
```typescript
|
||||
// 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.
|
||||
|
||||
```typescript
|
||||
// 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")?
|
||||
@@ -0,0 +1,166 @@
|
||||
# Compliance UX
|
||||
|
||||
This document describes the compliance user journey that is implemented today. It is intentionally limited to the current behavior in code and dashboard UI.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applies only to courses whose type is `COMPLIANCE`
|
||||
- The `Compliance` nav item only appears for `COMPLIANCE` courses
|
||||
- The current implementation uses one course with compliance history inside it
|
||||
- It does not create separate retake courses or a course-switching UI
|
||||
|
||||
## Key prerequisites
|
||||
|
||||
- A teacher/admin must create or configure the course as `COMPLIANCE`
|
||||
- The course must have `certificate.deadline` set before student enrollment can create the initial compliance cycle
|
||||
- Student completion is currently tied to the existing course certification rule, not a separate compliance-only completion rule
|
||||
|
||||
## Teacher/Admin Journey
|
||||
|
||||
### 1. Configure the course
|
||||
|
||||
The teacher/admin creates or updates a course to use the `COMPLIANCE` type.
|
||||
|
||||
For the compliance lifecycle to begin for learners, the course must also have a certificate deadline configured. That deadline is used as the initial `dueDate` for the first compliance cycle.
|
||||
|
||||
### 2. Enroll learners
|
||||
|
||||
When a student is enrolled into the course, the system attempts to create that learner's first compliance record automatically.
|
||||
|
||||
This automatic record creation currently runs when enrollment happens through:
|
||||
|
||||
- direct course member add
|
||||
- bulk course member add
|
||||
- course invite acceptance
|
||||
- organization invite acceptance when courses are assigned
|
||||
- audience-based course enrollment
|
||||
|
||||
If the course has no certificate deadline, compliance record creation is blocked.
|
||||
|
||||
### 3. View course compliance status
|
||||
|
||||
Staff can open the course `Compliance` page.
|
||||
|
||||
The page currently shows:
|
||||
|
||||
- summary cards for learner counts by status
|
||||
- a learner table with current status, due date, validity date, and score
|
||||
|
||||
This is an overview of the latest compliance record per learner, not a multi-cycle drilldown UI.
|
||||
|
||||
### 4. Take bulk staff actions
|
||||
|
||||
On the course `Compliance` page, staff can select learners and apply bulk actions:
|
||||
|
||||
- `Reset`
|
||||
- `Extend`
|
||||
- `Waive`
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `Reset` creates a first record if none exists, or resets the learner's latest record back to `not_started` with a new due date
|
||||
- `Extend` updates the due date on the learner's current incomplete record; if the learner has no current incomplete record and also has no completed record, it creates a new `not_started` record
|
||||
- `Waive` marks the learner's current incomplete record as `waived`
|
||||
|
||||
The page supports:
|
||||
|
||||
- select all visible learners
|
||||
- clear selection
|
||||
- staff-entered due date for reset/extend
|
||||
- optional waiver reason
|
||||
- optional waiver expiration date
|
||||
|
||||
### 5. Refresh data
|
||||
|
||||
The `Compliance` page has a refresh action. It clears the local compliance state and reloads the course data.
|
||||
|
||||
## Student Journey
|
||||
|
||||
### 1. Join the course
|
||||
|
||||
The student joins the `COMPLIANCE` course through one of the supported enrollment flows.
|
||||
|
||||
If the course is correctly configured, the system creates the student's first compliance cycle automatically with:
|
||||
|
||||
- status `not_started`
|
||||
- cycle number `1`
|
||||
- due date from `certificate.deadline`
|
||||
|
||||
### 2. Take the course normally
|
||||
|
||||
The student experience remains inside the same course shell. There is no separate retake course.
|
||||
|
||||
The student works through lessons and exercises using the existing course flow.
|
||||
|
||||
### 3. Open the Compliance page
|
||||
|
||||
Students see a `Compliance` tab for compliance courses.
|
||||
|
||||
The page currently shows:
|
||||
|
||||
- `Current Cycle`
|
||||
- `History`
|
||||
|
||||
`Current Cycle` displays the latest active record fields that are currently available in the system:
|
||||
|
||||
- cycle number
|
||||
- status
|
||||
- due date
|
||||
- completed at
|
||||
- valid until
|
||||
- score
|
||||
- attempts
|
||||
|
||||
`History` shows previous and current cycle records in a table.
|
||||
|
||||
### 4. Complete the course
|
||||
|
||||
Compliance completion is not triggered by a dedicated "mark complete" action on the compliance page.
|
||||
|
||||
Instead, the system evaluates compliance after submission activity. When the student becomes eligible under the existing course certification rule:
|
||||
|
||||
- the active compliance record is marked `compliant`
|
||||
- `completedAt` is stored
|
||||
- `validUntil` is calculated from the course retake interval
|
||||
- a per-cycle certificate issue record is created if certificates are downloadable for the course
|
||||
|
||||
If the student is not yet eligible for the course certificate, the compliance record remains incomplete.
|
||||
|
||||
## System-driven lifecycle
|
||||
|
||||
Some parts of the journey are implemented as backend jobs rather than dashboard actions.
|
||||
|
||||
### Reminder and status scan
|
||||
|
||||
The system has an internal reminder/status scan that can:
|
||||
|
||||
- mark a record `expiring_soon`
|
||||
- move a learner into `in_grace_period`
|
||||
- move a learner into `non_compliant`
|
||||
- create reminder notification-event records for configured reminder offsets
|
||||
|
||||
This logic is implemented in the backend. There is no teacher-facing reminder management UI yet.
|
||||
|
||||
### Renewal cycle creation
|
||||
|
||||
When a compliant cycle reaches `validUntil`, the expiry job:
|
||||
|
||||
- marks the old cycle as expired
|
||||
- marks its certificate issue as expired
|
||||
- creates the next cycle as `not_started`
|
||||
- sets the next cycle's due date to the previous cycle's `validUntil`
|
||||
|
||||
There is no separate student retake selector in the UI. The renewal appears as another cycle in the same course history.
|
||||
|
||||
## Current UI boundaries
|
||||
|
||||
The following are implemented in the backend but not yet exposed as full dashboard workflows:
|
||||
|
||||
- staff drilldown into a specific learner's compliance history from the staff overview page
|
||||
- reminder management UI
|
||||
- explicit certificate history UI tied to compliance cycles
|
||||
|
||||
The current dashboard UX is therefore:
|
||||
|
||||
- staff: overview + learner table + bulk actions
|
||||
- student: current cycle + cycle history
|
||||
Reference in New Issue
Block a user