Courses — Technical Reference
This page covers the data models, video hosting integration, student authentication, and edge routing architecture behind the Courses & Builder and Student Portal modules.
Data Model
courses
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | Tenant isolation — FK → workspaces.id (ON DELETE CASCADE) |
title | text | Course title |
description | text | Course description |
thumbnail_path | text | Storage path for course thumbnail in the workspace_assets bucket |
is_published | boolean | Publication status (gated from student access when false) |
auto_send_email | boolean | Auto-trigger email invitation on enrollment |
preview_token | uuid | Secret token used to bypass gating during preview mode |
preview_token_expires_at | timestamptz | Expiration time for preview token |
structure_updated_at | timestamptz | Tracks structural hierarchy modifications |
created_at | timestamptz | Record creation timestamp |
updated_at | timestamptz | Last updated timestamp |
Indexes:
idx_courses_workspaceon(workspace_id)idx_courses_preview_tokenon(preview_token) WHERE preview_token IS NOT NULL
course_modules
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
course_id | uuid | FK → courses.id (ON DELETE CASCADE) |
title | text | Module title |
sequence_order | integer | Order index in course structure |
created_at | timestamptz | Record creation timestamp |
Indexes:
idx_course_modules_orderon(course_id, sequence_order)
course_lessons
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
module_id | uuid | FK → course_modules.id (ON DELETE CASCADE) |
title | text | Lesson title |
description | text | Lesson text content or description |
sequence_order | integer | Order index within the module |
created_at | timestamptz | Record creation timestamp |
updated_at | timestamptz | Last updated timestamp |
Indexes:
idx_course_lessons_orderon(module_id, sequence_order)
course_lesson_blocks
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
lesson_id | uuid | FK → course_lessons.id (ON DELETE CASCADE) |
block_type | text | 'video', 'text', 'file', 'image', or 'audio' (CHECK constraint) |
sequence_order | integer | Order index within the lesson |
content | jsonb | Block configuration, text body, or asset URLs |
created_at | timestamptz | Record creation timestamp |
updated_at | timestamptz | Last updated timestamp |
Indexes:
idx_course_lesson_blocks_orderon(lesson_id, sequence_order)
course_enrollments
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | Tenant isolation — FK → workspaces.id (ON DELETE CASCADE) |
contact_id | uuid | FK → contacts.id (ON DELETE CASCADE) |
course_id | uuid | FK → courses.id (ON DELETE CASCADE) |
access_token | uuid | Secure token used for student portal access and cookies |
expires_at | timestamptz | Expiration timestamp (NULL if infinite access) |
revoked_at | timestamptz | Timestamp when student access was suspended |
completed_at | timestamptz | Timestamp when the student completed the course |
prior_completions | jsonb | Audit archive of progress state for previous attempts |
created_at | timestamptz | Record creation timestamp |
Constraints & Indexes:
- Unique constraint:
course_enrollments_contact_course_uniqueon(contact_id, course_id)idx_course_enrollments_access_tokenon(access_token)idx_course_enrollments_workspace_contacton(workspace_id, contact_id)idx_course_enrollments_active_counton(course_id) WHERE revoked_at IS NULLcourse_enrollments_completed_at_idxon(completed_at)
course_progress
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
enrollment_id | uuid | FK → course_enrollments.id (ON DELETE CASCADE) |
lesson_id | uuid | FK → course_lessons.id (ON DELETE CASCADE) |
last_position_seconds | numeric | Playback position for video resume |
completed | boolean | Flag indicating lesson completion |
completed_at | timestamptz | Timestamp of lesson completion |
updated_at | timestamptz | Last updated timestamp |
Constraints & Indexes:
- Unique constraint:
course_progress_enrollment_lesson_uniqueon(enrollment_id, lesson_id)idx_course_progress_lookupon(enrollment_id, lesson_id)
Workspace Column Extensions
| Column | Type | Description |
|---|---|---|
custom_portal_domain | text | Registered custom hostname for the learning portal |
custom_portal_verified | boolean | Verification status of custom CNAME configurations |
Architecture
Video Storage Integration (Bunny.net Stream)
The Courses module uses a tenant-isolated storage integration powered by Bunny.net Stream.
-
API Credentials Isolation:
- Platform Key: The server uses
process.env.BUNNY_API_KEYfor account-level actions (e.g., creating and deleting workspace video libraries). - Workspace Credentials: Stored dynamically within
settings.integrations.bunnyon theworkspacestable, includinglibrary_id,api_key(write/delete),read_only_api_key(signature verification), andtoken_auth_key(signed streaming).
- Platform Key: The server uses
-
Upload Handshake & TUS Protocol:
- Direct file uploads from the browser use
tus-js-clientto offload traffic from the application server. - The upload parameters request generates a dynamic signature using:
Signature = SHA256(libraryId + apiKey + expiresTimestamp + videoId) - This temporary signature is passed as a header to authorize the TUS upload directly to Bunny's servers.
- Direct file uploads from the browser use
-
Quota and Caching Mechanics:
- Quota Resolution Priority:
- Workspace settings override:
workspaces.settings.courses.storage_quota_gb - Platform default settings:
default_course_quota_gbinplatform_settings - Hard fallback:
5 GB
- Workspace settings override:
- Usage Tracking: Bunny's account-wide summaries have reporting delays. The server queries library usage in real-time by paging through the API and summing all video files'
storageSize. - Caching Strategy: Real-time storage counts are cached server-side using Next.js
unstable_cachewith a 5-minute TTL under the taglibrary-usage-[libraryId].
- Quota Resolution Priority:
-
Webhook & Post-Processing Guard:
- The
/api/courses/bunny-webhookroute receives progress notifications. - The server verifies signatures via the
x-bunnystream-signatureheader against a SHA256 hash of the request body generated with the library'sread_only_api_key. - When status
3(FINISHED) is received, the server checks the workspace's quota. If the quota is exceeded:- The video is immediately purged via
deleteVideo. - The database lesson block is updated with an error payload (
error: "Video removed: exceeded workspace storage quota"). - An in-app alert is generated for the workspace admins.
- The library usage cache tag is invalidated.
- The video is immediately purged via
- The
Security
Row-Level Security (RLS)
| Table | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
courses | Enrolled students, workspace members | Workspace members | Workspace members | Admin / Owner |
course_modules | Enrolled students, workspace members | Workspace members | Workspace members | Workspace members |
course_lessons | Enrolled students, workspace members | Workspace members | Workspace members | Workspace members |
course_lesson_blocks | Enrolled students, workspace members | Workspace members | Workspace members | Workspace members |
course_enrollments | Matching student access_token, workspace members | Workspace members | Workspace members | Admin / Owner |
course_progress | Matching student access_token, workspace members | Student, workspace members | Student, workspace members | Admin / Owner |
Student Authentication
-
Authentication Token:
- Access tokens are generated as random
UUIDvalues and stored incourse_enrollments.access_token. - When a student lands on
/c/[courseId]?token=[accessToken], the server validates the token and sets a session cookie:course_access_[courseId]. - Subsequent navigation to lessons resolves the token from this cookie.
- Access tokens are generated as random
-
Access Gating & Verification Rules:
- Preview Mode: If a
preview_tokenmatches and is not expired (preview_token_expires_at > NOW()), all validation checks (publication status, draft blocks, legal consent) are bypassed. - Standard Gating: The validation pipeline verifies:
- The enrollment has not expired (
expires_at IS NULLorexpires_at > NOW()). - Access has not been suspended (
revoked_at IS NULL). - The course is published (
is_published = true). - Legal Consent Gate: If the workspace has active Terms of Service or Privacy policies and the student has not yet acknowledged them (checked via
contact_policy_acknowledgements), access is restricted. The server returnsrequiresConsent = true, instructing the frontend to mask lesson blocks and overlay a policy acceptance modal.
- The enrollment has not expired (
- Preview Mode: If a
Routing
Vercel Edge Config Custom Domain Routing
Next.js Middleware intercepts incoming portal requests to support instant dynamic custom domains with minimal database latency.
- Key Sanitization:
- Vercel Edge Config does not support dots (
.) in keys. Domain names are sanitized:edgeKey = hostname.replace(/\./g, "_")Example:academy.clientdomain.commaps toacademy_clientdomain_com.
- Vercel Edge Config does not support dots (
- REST Item Lookup:
- To avoid loading the entire config map into Edge memory, the middleware targets the exact item key endpoint directly:
const url = connectionString.replace(/(\/ecfg_[a-zA-Z0-9]+)(\?|$)/, `$1/item/${edgeKey}$2`); - This achieves lookup times of under 15ms.
- To avoid loading the entire config map into Edge memory, the middleware targets the exact item key endpoint directly:
- Rewrite & Redirect Logic:
- Internal Rewriting: If routing matches a verified custom portal domain, incoming paths matching
/c/[courseId]are rewritten internally to/learn/c/[courseId], leaving the custom hostname unchanged in the address bar. - Default Domain Redirection: If a user hits the default portal domain
learn.gordoncrm.com/c/[courseId]but the course owner has verified acustom_portal_domain, the landing page initiates a302 Temporary Redirectto the custom domain. - Admin Subdomain Isolation: Any path starting with
/learn/c/...on the main app subdomainapp.gordoncrm.comtriggers a301 Permanent Redirectto the default portallearn.gordoncrm.com, isolating admin cookies from student cookies.
- Internal Rewriting: If routing matches a verified custom portal domain, incoming paths matching