feat: complete rewrite of course (#622)

* theming improvements

* border style improvements

* refactor: migrate org routes to server-side data fetching

Move client-side API calls to server-side load functions for better
performance, security, and SEO. Implement permission-based course filtering

* refactor: clean up course and community components

This commit removes unused components and files related to courses and community features, streamlining the codebase. It also updates various routes to enhance performance and maintainability. Additionally, it introduces new types and constants for better type safety and organization.

* refactor: fix video formatting on website and other styles

* refactor: update course and dashboard components for improved styling and functionality

* more icon improvements

* refactor: streamline course and dashboard components

This commit updates various course and dashboard components for improved styling and functionality. It includes the removal of unused files, updates to imports and exports, and enhancements to type safety. Additionally, it introduces new routes and optimizes existing ones for better performance and maintainability.

* refactor: enhance lesson completion logic

* chore: delete all emails on dashboard and move other api routes to backend.

move all emails to the backend and

* feat: Refactor API and database queries for courses, quizzes, and lessons, migrating Supabase functions and schema management.

* get app loading

* fix ui and build error of cio/api

* refactor: move direct database queries to query layer

- Create query functions: getCourseWithOrgData, getOrganizationByCourseId, updateCourseVersion, updateLessonsSectionId, getProfileByGroupMemberId, getQuestionTypesByIds
- Refactor services to use query functions instead of direct db queries
- Add transaction support to updateCourseVersion and updateLessonsSectionId
- Fix getProfileByGroupMemberId to return profile directly (not nested)
- Fix updateLessonsSectionId to use .returning() instead of rowCount

This aligns services with architecture pattern where services orchestrate queries rather than executing them directly.

* exercise saving improvements

* fix and optimise exercise creation

* refactor: update dialog components for improved styling and functionality

- Adjusted dialog content width and maximum constraints for better responsiveness.
- Changed heading font size for consistency in the new exercise modal.
- Replaced CloseIcon with CrossIcon for a more modern appearance in dialog close button.

* refactor: enhance course and exercise management components

- Introduced new components for exercise creation, editing, and viewing, improving user experience.
- Refactored existing lesson and exercise components for better organization and maintainability.
- Updated routing for exercises and lessons to streamline navigation within the course structure.
- Added new utility functions and constants to support content management across the application.

* feat: implement course content management API and UI components

- Added new API routes for updating and deleting course content, enhancing content management capabilities.
- Introduced new Svelte components for displaying and managing course content, including content lists and action handlers.
- Updated existing course routing to integrate new content management features.
- Refactored content-related utility functions for improved organization and maintainability.

* refactor: remove Supabase references and implement course section management

- Removed Supabase-related environment variables and Dockerfile arguments to streamline configuration.
- Introduced a new section management API with routes for creating, updating, deleting, and reordering course sections.
- Updated course services to handle section operations, ensuring consistency in course content management.
- Refactored existing lesson section logic to align with the new course section structure, enhancing maintainability.

* chore: update .prettierignore to exclude additional file types

- Added exclusions for SQL and environment files to the Prettier ignore list.
- Included Dockerfile and .prettierignore itself to prevent formatting issues.

* feat: enhance course content management with translation support and UI improvements

- Added translation keys for user-facing text in course-related components, ensuring all UI copy is sourced from translation files.
- Updated lesson and exercise creation components to utilize translation keys for success and error messages, improving localization.
- Refactored course content management components to streamline the creation and editing process, enhancing user experience.
- Introduced new constants for content options and stepper states to standardize component behavior and improve maintainability.
- Improved styling and functionality of course content modals and steppers for better user interaction.

* feat: enhance course content management and UI components

- Updated .prettierignore to exclude storybook-static directory.
- Improved exercise creation logic by refining option handling and adding UUID support for option values.
- Enhanced navigation component with debugging output for custom links.
- Introduced a new method in CourseApi to update content items locally, improving state management during navigation.
- Added caching for exercise templates by tag in ExerciseTemplateApi to optimize performance.
- Refactored lesson and exercise components for better organization and maintainability, including updates to modal styling and functionality.
- Implemented local patching for lesson and exercise list items to ensure UI consistency after updates.

* student + teacher invite (not fully tested)

* refactor: streamline course team member middleware

- Removed unnecessary console logging and timing for checking user permissions.
- Simplified the logic for determining if a user is a course team member or organization admin.

* reorder course sidebar to move content to its own page.

* feat: implement unified course enrollment route and API

- Introduced a new enrollment route `/course/:slug/enroll` to replace the previous invite system.
- Unified enrollment logic for free and paid courses, allowing direct enrollment for free courses and invite token validation for paid courses.
- Updated course landing page to redirect to the new enrollment route for free courses.
- Removed obsolete invite routes and related logic, streamlining the enrollment process.
- Enhanced API with a new `POST /course/:courseId/enroll` endpoint for handling enrollments.
- Added rate limiting for enrollment requests to prevent abuse.
- Updated UI components to reflect changes in enrollment flow and messaging.

* chore: update docker stack, docs, and auth token automation

* chore: make prettier check ignore unknown file types

* fix: avoid using authClient on server file and keep authClient client focused

* chore: always get the server on demand depending on the environment

* chore: update dashboard configuration and Docker setup for environment variables

- Modified the Svelte configuration to directly use the PUBLIC_SERVER_URL environment variable.
- Enhanced the Docker Compose file to include health check formatting and added environment variable arguments for the dashboard build.
- Updated the Dockerfile to echo environment variable values during the build process, ensuring clarity on the configuration being used.

* chore: enhance Docker setup and documentation for production deployment

- Added a new production Docker Compose file and example environment file for published images.
- Updated README and usage documentation to include instructions for deploying with production images.
- Modified Svelte configuration to standardize the use of the posthog domain.
- Removed unnecessary console logs from the API client and layout server files for cleaner output.
- Improved overall clarity in Docker documentation regarding self-hosting and image publishing.

* chore(db): log function name in query catch blocks (console.error 'funcName error:', error)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: multiple refresh issue

* fix: server key not passed to api

* fix: clarify usage of PRIVATE_SERVER_KEY and AUTH_BEARER_TOKEN in documentation

- Updated documentation to specify that PRIVATE_SERVER_KEY is used for server-to-server API key authentication and is validated by API key middleware.
- Clarified that AUTH_BEARER_TOKEN is a separate token not utilized by apiKeyMiddleware.
- Adjusted examples in Docker setup and self-hosting documentation to reflect these changes.

* fix migration script

* fix: improve language handling and add database reset script

- Updated language extraction to handle potential undefined values, defaulting to 'en' if necessary.
- Added a new script for resetting the database, including functionality to truncate public tables and run setup with optional seeding.

* feat: add promote ungrouped section functionality and enhance document handling in dashboard

- Implemented a new API endpoint to promote synthetic "Ungrouped" content into a real persisted section, allowing for better organization of course materials.
- Updated the dashboard to include a new component for displaying content count badges for lessons and exercises.
- Enhanced document management features, including a new document card component with dropdown actions for viewing, downloading, and removing documents.
- Improved file upload handling with a dedicated file drop zone and error handling for file type and size restrictions.
- Refactored existing components for better readability and maintainability, including updates to lesson and document lists.

* chore: use root env file for docker compose

* docs: document docker no-build startup flow

* chore: add no-build option to docker full stack script

* fix(email): handle SMTP TLS mode by port

* feat(auth): add configurable cookie domain env and docs

* apps/dashboard/src/lib/utils/services/auth/session.ts

* fix(email): make sender configurable via SMTP_SENDER

* chore(i18n): regenerate locale translations and support language targeting

* chore(config): switch api to PUBLIC_SERVER_URL

* fix(i18n): persist selected locale across navigation

* feat(assets): implement organization asset management routes and services

- Added new routes for managing organization assets, including listing, creating, updating, deleting, and exporting assets.
- Introduced asset-related services for handling asset operations such as creation, retrieval, and deletion.
- Integrated YouTube metadata resolution and asset attachment/detachment functionalities.
- Updated organization routes to include the new asset management routes.
- Enhanced error handling for asset-related operations.

* fix(document-list): update conditional rendering for edit mode in document list

* debug self hosted layout setup

* refactor(sidebar): simplify lms sidebar footer and improve navigation item rendering

* feat: tags

* chore: tags roadmap + translations

* fix(api): add authMiddleware to exercise, invites, and content routers

- exerciseRouter: apply authMiddleware before courseMemberMiddleware on all 10 routes
- invitesRouter: apply authMiddleware before courseTeamMemberMiddleware on all 4 routes
- contentRouter: add authMiddleware to PUT route (DELETE already had it)

Both courseMemberMiddleware and courseTeamMemberMiddleware document requiring
authMiddleware to be applied first. This aligns with lessonRouter pattern and
ensures explicit middleware contract regardless of global session middleware.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(course): use courseTeamMemberMiddleware for PUT, DELETE, and analytics routes

These routes require admin/tutor role per JSDoc but were using
courseMemberMiddleware which allows any course member including students.
Switch to courseTeamMemberMiddleware to restrict access to ADMIN/TUTOR
group members and org admins.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* chore: unify pricing component between dashboard and website (#630)

* feat: modularize pricing card

* feat: initia implementation of pricing togge

* fix: minor amendment

* refactor: update pricing components for improved layout and styling

- Adjusted spacing in the pricing card layout for better visual consistency.
- Modified button styles and sizes for enhanced usability.
- Cleaned up the pricing toggle component by removing unnecessary console logs and restructuring the button rendering logic.

* add handle click

* chore(css): update Tailwind CSS output with new utility classes and theme variables

* fix(api): enable orgMemberMiddleware for stats route

This change ensures that the stats route is protected by orgMemberMiddleware, aligning access control with other routes. Additionally, removed unnecessary console log from the layout server file to clean up the code.

---------

Co-authored-by: Rotimi Best <best@classroomio.com>

* pass query parameter to signup from pricing page

* ui updates to pricing cards

* commit all PRDs

* add open api spec to api prd

* make blue default theme

* more prds

* sync option id sequence on question update

* docs: more classroomio features

* chore: more ui improvements

* docs: new component docs

* chore: CSP Configuration (#638)

* feat(validation): add GitHub repository name validation

- Add ZRepositoryName schema for validating GitHub repo names
- Add ZRepositoryOwner for owner/username validation
- Add ZRepositoryIdentifier for owner/repo format
- Follow GitHub rules: A-Za-z0-9_.-, 1-100 chars, reserved names
- Add translation keys for validation messages

Fixes #637

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* Revert "feat(validation): add GitHub repository name validation"

This reverts commit 3436536bb17a0f05b3a9fe8dd7682cde64dd1dce.

* feat(dashboard): make CSP external domains configurable via env

Move hardcoded external domains from svelte.config.js to .env configuration
for on-premise deployments with strict security policies, data residency
constraints, or CISO-driven allowlists.

- ALLOWED_EXTERNAL_DOMAINS: single list for all CSP directives
- CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS,
  CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS: per-directive override
- When unset, defaults retain current SaaS behavior (backward compatible)

Fixes #637

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): self-hosted CSP uses only env vars, no defaults

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): add all classroomio.com domains to CSP defaults

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(docker): pass CSP domain env vars as build args to dashboard

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: add SSO and token authentication routes and services

- Introduced new routes for SSO and token authentication under the organization namespace.
- Implemented SSO connection management including creation, update, activation, and deletion.
- Added token authentication configuration with support for secret rotation and status retrieval.
- Enhanced error handling for SSO and token authentication processes.
- Updated existing routes and services to integrate new functionalities.
- Improved gradebook service to fetch student marks and exercises.
- Refactored API structure for better organization and maintainability.

* update translations

* fix: add PUBLIC_SERVER_URL to .env.example to prevent localhost fallback in production (#641)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat: new exercise question types

* MinIO S3 storage backend (#639)

* feat(storage): add MinIO/S3-compatible object storage backend

- Add OBJECT_STORAGE_* env vars for generic S3-compatible backends (MinIO, AWS S3)
- Create storage config module with Cloudflare R2 fallback for backward compatibility
- Refactor s3.ts to use configurable client and bucket names from config
- Update media.ts to use provider-neutral public URL (OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL or CLOUDFLARE_IMAGE_BUCKET_DOMAIN)
- Add MinIO service and init to docker-compose (profile: minio) with videos/documents/media buckets
- Add localhost:9000 to dashboard CSP for local MinIO presigned URL uploads
- Update self-hosting and Cloudflare docs with MinIO option
- Update .env examples and docker USAGE.md

Fixes #635

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs: add MINIO_ROOT_USER and MINIO_ROOT_PASSWORD to self-hosting guides

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(media): refactor media management and integrate MinIO support

- Updated media API to use a new `mediaApi` module, replacing `mediaManagerApi` references across components.
- Added MinIO configuration options to the Docker setup, allowing for optional object storage exclusion.
- Enhanced environment variable management for MinIO in the `.env.example` file.
- Updated Content Security Policy (CSP) to allow local MinIO access in the dashboard.
- Introduced new media components for asset management, including asset cards and usage dialogs.

This refactor improves the modularity of the media handling system and provides better support for S3-compatible storage solutions.

* feat(dashboard): enhance logging and refactor auth session handling

- Added console logging for event and environment variables in hooks.server.ts to aid debugging.
- Refactored auth session handling to use authServerClient instead of authClient in session.ts for improved consistency.
- Updated Dockerfile to ensure the dashboard build output is present before starting the application.
- Expanded self-hosting documentation to include troubleshooting steps for disk space issues in PostgreSQL and Docker.

These changes improve the observability of the application and streamline the authentication process.

* refactor(dashboard): remove debug logging and enhance Postgres healthcheck

- Removed console logging for event and environment variables in hooks.server.ts to clean up the code.
- Updated Postgres healthcheck in docker-compose.yaml to require a successful `SELECT 1` query, ensuring dependent services only start once the database is ready.
- Expanded self-hosting documentation to include troubleshooting steps for database recovery issues.

These changes improve code clarity and reliability in service startup sequences.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: add Plyr media player icons and PDF.js integration

- Introduced new Plyr SVG icons for media player controls in both course and dashboard applications.
- Updated Plyr player configuration to use local icon assets.
- Integrated PDF.js for document viewing, replacing external CDN links with local paths for improved performance and reliability.
- Enhanced PDF viewer modal z-index for better visibility.

This update improves the media player experience and ensures consistent access to necessary resources.

* feat: enhance account and organization services with type definitions and promise return types

- Updated `getAccountData` to return a structured result type including profile and organizations.
- Modified `getOrganizationsWithFilters` to specify a promise return type for better type safety.
- Added new type imports for organization-related data structures to improve clarity and maintainability.

These changes improve type safety and enhance the overall structure of the account and organization services.

* feat: implement Redis caching for presigned URLs and refactor API interactions

- Added Redis caching for presigned download URLs in the API to reduce redundant S3 calls, improving performance.
- Introduced a new `PresignApi` class in the frontend to centralize presigned URL requests, enhancing code maintainability.
- Updated various components to utilize the new `presignApi` methods for document and video upload/download operations.
- Enhanced error handling for Redis operations to ensure graceful degradation in case of cache failures.

These changes optimize the presigned URL generation process and streamline API interactions across the application.

* fix: add better-auth dependency to dashboard (#645)

* chore: unify exercise answer data format

* feat: make selfhosting run under one org for simplicity (#647)

* feat: implement self-hosting PRD + license key system

Self-hosting single-org:
- Add PUBLIC_IS_SELFHOSTED to API env, block org creation when org exists
- Auto-add non-members as students in GET /account bootstrap
- getOrgSiteInfo: root domain returns single org for self-hosted
- App init: route to /lms when self-hosted + no orgs
- Hide org switcher and Add Org modal when self-hosted

Invite-only signup:
- Add settings.signup.inviteOnly via ZUpdateOrganization
- Allow public signups toggle in org auth settings
- Signup page: hide form when invite-only and no invite context
- Landing page nav: respect inviteOnly for signup link

First signup auto-verify:
- Self-hosted first signup (no orgs) auto-verifies email in createProfileHook

License key system:
- LICENSE_KEY, LICENSE_VERIFICATION_URL for external verification API
- Feature gates: SSO, token-auth, no-tracking (PostHog/Senja)
- requireLicense middleware for API routes
- Dashboard: license store, gate PostHog/Senja/SSO/token-auth UI
- docs/LICENSE_API_CONTRACT.md for external API spec

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat: get rid of carbon completely

* chore: license verification happens only for selfhosted instance

* fix: in self hosted instance, orgSite is the default

* docs: how to write math in cio editor

* Update packages/db/src/queries/organization/organization.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/api/src/services/license.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: translations, cursor pr reviews, simplify license fetching and check

* refactor: update analytics setup for cloud and self-hosted deployments; enhance settings merge logic

* refactor: update license fetching URL for development environment; optimize organization query to prevent plan row loss

* more selfhosting improvements

* feat: enhance signup guard for self-hosted instances by requiring organization context when no org ID is provided

* pr reviews

* fix: correct query parameter handling in course navigation

* pr reviews

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: Abdulmuizz Hamzat <103611443+buildwithhamzat@users.noreply.github.com>
Co-authored-by: Adeyemi Adetunji <tvnji01@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-03-09 11:18:22 +01:00
committed by GitHub
co-authored by Rotimi Best greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Cursor Agent Abdulmuizz Hamzat Adeyemi Adetunji
parent a42565ad82
commit 1e9a82e912
1196 changed files with 118809 additions and 74705 deletions
+280
View File
@@ -0,0 +1,280 @@
---
name: AI Course Assistant
overview: An AI-powered assistant embedded in the course UI that helps teachers organize content—update lesson text, tag exercises, generate questions from existing content, and draft lessons. Cloud-only; self-hosted instances cannot use this feature.
todos:
- id: prd-review
content: Finalize PRD with stakeholders, resolve open questions
status: pending
- id: architecture-decision
content: Decide agent execution model (API-first vs local-first)
status: pending
- id: package-scaffold
content: Create packages/ai-assistant package with API client
status: pending
- id: agent-api-routes
content: Design and implement agent API routes with auth + org scoping
status: pending
- id: chat-ui
content: Build chat interface in right sidebar (Sheet)
status: pending
- id: nav-integration
content: Add AI assistant button to course navigation/header
status: pending
- id: self-host-gate
content: Hide assistant and block agent routes when PUBLIC_IS_SELFHOSTED
status: pending
- id: safeguards
content: Enforce org/course permission checks in all agent actions
status: pending
isProject: false
---
# AI Course Assistant
## Purpose
Deliver an AI assistant inside the course UI that teachers can use to:
- **Update content**: "Update this piece of text in this lesson"
- **Tag exercises**: Tag an exercise and provide context
- **Generate questions**: "Given this text, come up with 5 new questions"
- **Draft lessons**: Ask the agent to write the initial draft of a lesson
The assistant operates in a right sidebar chat interface, triggered by an "AI Assistant" button in the course navigation.
## Goals
- Help teachers organize and author course content faster
- Operate strictly within the current org/course context (no cross-org access)
- Integrate natively with existing ClassroomIO APIs (lessons, exercises, content)
- Cloud-only; self-hosted users cannot use the AI assistant
## Non-Goals (v1)
- Student-facing AI (tutors/teachers only)
- Cross-course or cross-org operations
- Offline or fully local execution without API
- Custom model selection or fine-tuning
---
## Architecture Overview
```mermaid
flowchart TB
subgraph client [Dashboard Client]
NavButton["AI Assistant Button"]
ChatUI["Right Sidebar Chat"]
end
subgraph cloud [Cloud / API]
AgentService["Agent Service (packages/ai-assistant or API)"]
CourseAPI["Course / Lesson / Exercise APIs"]
end
subgraph db [Database]
LessonTable["lesson / lesson_language"]
ExerciseTable["exercise / exercise_template"]
ContentTable["course_content"]
end
NavButton -->|"Open"| ChatUI
ChatUI -->|"User message + context"| AgentService
AgentService -->|"Validate + Call"| CourseAPI
CourseAPI -->|"Persist"| db
CourseAPI -->|"Response"| ChatUI
```
---
## 1. Execution Model: API vs Local
**Open question**: How does the agent apply changes—via API directly, or by suggesting edits locally in the UI?
### Option A: API-First (Recommended for v1)
- Agent sends **structured intents** (e.g. `{ type: "update_lesson_text", lessonId, locale, payload }`) to a dedicated **agent API**
- Agent API validates permissions, calls existing services (`updateLessonService`, `updateLessonLanguageService`, etc.), returns results
- Client receives success/failure and refreshes or updates the relevant store
- **Pros**: Single source of truth, audit trail, reuses existing safeguards
- **Cons**: Slightly more latency; requires agent backend
### Option B: Local-First
- Agent returns **edit suggestions** as structured data (e.g. JSON patches)
- Client applies changes in the editor UI; user must explicitly accept (e.g. "Apply" button)
- Actual persistence happens through existing APIs when user saves
- **Pros**: Lower latency, no new backend routes
- **Cons**: More complex client logic; risk of sync drift; harder to audit
### Recommendation
**Start with Option A (API-First)**:
1. Reuse existing `courseTeamMemberMiddleware`, `orgMemberMiddleware`, and org/course checks
2. Agent executes actions via the same services used by the UI
3. Simpler mental model: agent is a "programmatic teacher" that talks to the same API
4. Easier to add rate limits, logging, and abuse prevention later
---
## 2. UI Specification
### Placement
- **Button**: "AI Assistant" in the **course header** (alongside Search, Notifications), visible only to teachers (not students)
- **Panel**: Right sidebar implemented with `@cio/ui` Sheet (`side="right"`), ~400px width on desktop
- **Mobile**: Full-screen sheet or drawer (reuse `lesson-version-history.svelte` pattern)
### Chat Interface
- Message list with user/assistant turns
- Text input at bottom with send button
- Optional: quick actions / suggested prompts (e.g. "Draft a lesson", "Generate 5 questions")
- Loading state while agent processes
- Error states with retry
### Context Awareness
- Pass `courseId`, `orgId`, `lessonId` (if viewing a lesson), `exerciseId` (if viewing an exercise) to the agent
- Agent can reference: "Update the text in the lesson you're viewing" without the user specifying IDs
---
## 3. Safeguards
### Authorization (Reuse Existing Middleware)
All agent API routes must use:
| Middleware | Purpose |
|------------|---------|
| `authMiddleware` | Require authenticated user |
| `orgMemberMiddleware` | Require `cio-org-id` and org membership |
| `courseTeamMemberMiddleware` | Require course team (ADMIN/TUTOR) or org admin |
### Scoping Rules
1. **Org scope**: Every action is scoped to the org in `cio-org-id` header
2. **Course scope**: Lesson/exercise edits must belong to a course where the user is a team member
3. **No cross-org**: Agent must never accept or execute requests for lessons/exercises in another org
### Implementation
- Agent API extracts `courseId`, `lessonId`, `exerciseId` from request payload
- Before any mutation, verify:
- Course belongs to the org
- User is `courseTeamMember` or `orgAdmin`
- Reuse `isCourseTeamMemberOrOrgAdmin` and `isUserOrgTeamMember` from `@cio/db/queries`
---
## 4. Self-Hosted vs Cloud
### Cloud (ClassroomIO-Hosted)
- AI assistant is **enabled** for teachers (within plan limits, if any)
- ClassroomIO provides the LLM API keys; no user configuration needed
### Self-Hosted
- AI assistant is **not available**. Self-hosters cannot use this feature.
- **Implementation**: Hide the "AI Assistant" button when `PUBLIC_IS_SELFHOSTED === 'true'`. Agent API routes return `403` or are not registered when self-hosted.
---
## 5. Package Design: `packages/ai-assistant`
The agent should live in a **package** that works natively with ClassroomIO APIs.
### Package Responsibilities
- **SDK / client**: Types and helpers for calling the agent API from the dashboard
- **Shared types**: Intent schemas, message formats
- **No UI**: UI stays in `apps/dashboard`; package is backend + types
### Suggested Structure
```
packages/ai-assistant/
src/
index.ts # Public exports
types.ts # Message, intent, response types
client.ts # Agent API client (optional; dashboard may use classroomio RPC)
package.json
tsconfig.json
```
### Agent API (in `apps/api`)
- New routes under e.g. `/course/:courseId/agent` or `/organization/agent`
- Routes: `POST /chat` (stream or non-stream), `POST /execute` (for structured intents)
- Agent service: interpret user message, map to intents, call lesson/exercise services, return result
### Complexity Estimate
| Component | Complexity | Notes |
|-----------|------------|-------|
| Package scaffold | Low | Thin wrapper around types + optional client |
| Agent API routes | Medium | Auth, validation, orchestration |
| LLM integration | Medium–High | Depends on provider (OpenAI, Anthropic, etc.) |
| Chat UI | Medium | Standard chat + Sheet |
| Self-host gate | Low | Env check + conditional render |
---
## 6. Data Sources & APIs (Existing)
| Action | API / Service | Auth |
|--------|---------------|------|
| Update lesson text | `PUT /course/:courseId/lesson/:lessonId/language/:locale` | courseTeamMember |
| Update lesson metadata | `PUT /course/:courseId/lesson/:lessonId` | courseTeamMember |
| Tag exercise (template tag) | Exercise template has `tag`; create/update exercise | courseTeamMember |
| Tag course (org tags) | `PUT /course/:courseId/tags` | courseTeamMember |
| Create lesson | `POST /course/:courseId/lesson` | courseTeamMember |
| Create exercise | Exercise creation flow | courseTeamMember |
| Get lesson content | `GET /course/:courseId/lesson/:lessonId` | courseMember |
Agent actions should call these through the existing service layer, not bypass them.
---
## 7. Suggested Implementation Phases
### Phase 1: Foundation
1. Add "AI Assistant" button to course header (hidden when self-hosted)
2. Add right Sheet with placeholder chat UI (no backend)
3. Create `packages/ai-assistant` with types and client stubs
4. Add agent API route skeleton with `courseTeamMemberMiddleware`
### Phase 2: Chat + Execution
1. Implement agent API `POST /chat` with LLM integration
2. Implement intent parsing: update lesson, generate questions, draft lesson
3. Wire agent to `updateLessonService`, `createLesson`, exercise services
4. Connect chat UI to API; display responses and errors
### Phase 3: Polish
1. Add quick-action prompts
2. Add context injection (current lesson/exercise)
3. Rate limiting and usage tracking (if needed)
---
## 8. Open Questions
1. **Streaming**: Should responses stream (SSE/WebSocket) or be non-streaming for v1?
2. **LLM provider**: OpenAI, Anthropic, or configurable per deployment?
3. **Rate limits**: Per-user, per-org, or per-course? Needed for cloud cost control?
4. **Exercise tagging**: Does "tag an exercise" mean org-level course tags, or exercise-template tags (e.g. mcq, coding)? Clarify semantics.
5. **Draft persistence**: When agent drafts a lesson, does it create a draft immediately or return text for user to paste? Recommendation: create draft via API, user reviews before publishing.
---
## 9. Success Criteria
- Teachers can open the AI assistant from the course header and chat in a right sidebar
- Agent can update lesson text, create draft lessons, and generate questions (as defined in Phase 2)
- No user can modify another org's data; all actions pass permission checks
- Self-hosted instances cannot use the assistant; UI and API are gated
- Agent package integrates cleanly with existing API and dashboard structure
@@ -0,0 +1,66 @@
# AI Course Assistant Implementation Plan
## Scope Lock (v1)
1. AI assistant is teacher-only (course team members; not students).
2. Agent executes via API (no local-only execution).
3. All actions are org/course-scoped; no cross-org access.
4. Self-hosted: assistant not available; hide UI and block agent routes.
5. Chat lives in a right sidebar (Sheet) from the course header.
6. Supported intents: update lesson text, generate questions from text, draft lesson; exercise tagging TBD (clarify org tags vs template tags).
## Delivery Phases
1. Phase A: Package + types + self-host gate
2. Phase B: Agent API routes + permission checks
3. Phase C: Chat UI + header button
4. Phase D: LLM integration + intent execution
5. Phase E: Polish (quick actions, context, rate limits)
## Ticket Breakdown
| ID | Area | Task | Key Files | Dependencies | Done When |
| --- | --- | --- | --- | --- | --- |
| AI-A1 | Package | Create `packages/ai-assistant` with types (Message, Intent, Response) | `packages/ai-assistant/src/types.ts` | None | Package builds; types exportable |
| AI-A2 | Package | Add optional API client for agent chat | `packages/ai-assistant/src/client.ts` | AI-A1 | Client can call agent endpoints (or dashboard uses RPC directly) |
| AI-A3 | Dashboard | Add `isAiAssistantAvailable` util (false when `PUBLIC_IS_SELFHOSTED`) | `apps/dashboard/src/lib/utils/ai-assistant.ts` | None | Util returns bool; hide button when self-hosted |
| AI-B1 | API | Add agent validation schemas (chat payload, intent payloads) | `packages/utils/src/validation/agent/` | None | Zod schemas for chat + intents |
| AI-B2 | API | Add agent routes under `/course/:courseId/agent`; do not register or return 403 when self-hosted | `apps/api/src/routes/course/agent.ts` | AI-B1 | `POST /chat`, `POST /execute` (or combined); blocked when `PUBLIC_IS_SELFHOSTED` |
| AI-B3 | API | Apply auth + courseTeamMember middleware to agent routes | `apps/api/src/routes/course/agent.ts` | AI-B2 | Unauthorized users get 403 |
| AI-B4 | API | Add agent service: parse message → intent → call lesson/exercise services | `apps/api/src/services/agent.ts` | AI-B2 | Service orchestrates intents; returns structured response |
| AI-B5 | API | Verify org/course scoping in every agent mutation | `apps/api/src/services/agent.ts` | AI-B4 | Cross-org requests rejected |
| AI-C1 | Dashboard | Add AI Assistant button to course header | `apps/dashboard/src/lib/features/course/components/course-header.svelte` | AI-A3 | Button visible when `isAiAssistantAvailable` and teacher |
| AI-C2 | Dashboard | Add right Sheet with chat UI (message list + input) | `apps/dashboard/src/lib/features/ai-assistant/ai-assistant-sheet.svelte` | AI-C1 | Sheet opens from button; placeholder chat |
| AI-C3 | Dashboard | Create AI assistant feature module (store, API class) | `apps/dashboard/src/lib/features/ai-assistant/` | AI-A1 | API class calls agent endpoint; store holds messages |
| AI-C4 | Dashboard | Wire chat input → API → message display | `apps/dashboard/src/lib/features/ai-assistant/` | AI-C3, AI-B2 | User can send message; response shows in chat |
| AI-D1 | API | Integrate LLM provider (OpenAI/Anthropic) for chat completion | `apps/api/src/services/agent.ts` | AI-B4 | Agent can generate text from user message |
| AI-D2 | API | Implement intent: update lesson text | Agent service + lessonLanguage service | AI-D1 | "Update this text..." → `updateLessonLanguageService` |
| AI-D3 | API | Implement intent: generate N questions from text | Agent service | AI-D1 | Returns generated questions as text; user copies or we add create flow later |
| AI-D4 | API | Implement intent: draft lesson | Agent service + `createLesson` | AI-D1 | Creates draft lesson; returns lesson ID for navigation |
| AI-D5 | API | Clarify and implement exercise tagging intent | Agent service + tag/course APIs | AI-D1 | Semantic clarified in PRD; implement |
| AI-E1 | Copy | Add i18n keys for AI assistant UI | `apps/dashboard/src/lib/utils/translations/en.json` | AI-C2 | No hardcoded strings |
| AI-E2 | API | Add rate limiting for agent endpoints | Middleware or route-level | AI-B2 | Prevents abuse; configurable limit |
| AI-E3 | Tests | Add auth/role tests for agent routes | API tests | AI-B3 | Unauthorized/student requests return 403 |
## API Contract (Target)
### Agent Chat
- `POST /course/:courseId/agent/chat`
- Body: `{ message: string, context?: { lessonId?, exerciseId? } }`
- Response: `{ success: true, data: { message: string, intent?: string, actions?: [...] } }`
- Auth: `authMiddleware`, `courseTeamMemberMiddleware`
### Optional Structured Execute
- `POST /course/:courseId/agent/execute`
- Body: `{ intent: string, payload: Record<string, unknown> }`
- For tool-calling or explicit intent execution (e.g. from LLM tool use)
- Auth: same as above
## Open Items Before Implementation
1. LLM provider choice (OpenAI vs Anthropic vs configurable).
2. Streaming: v1 non-streaming vs SSE streaming.
3. Exercise tagging semantics (org course tags vs exercise template tags).
4. Draft lesson: create immediately vs return text for paste.
+993
View File
@@ -0,0 +1,993 @@
# Classes PRD
## Purpose
Enable instructors and org admins to group multiple courses — along with shared announcements, files, and community discussions — into a single **class**. Enrolling a student in a class auto-enrolls them in every course inside it, eliminating the need to manage per-course enrollment manually when delivering a structured program.
## Problem Statement
Currently, ClassroomIO has no entity above the individual course. Every course has its own independent enrollment roster. This creates several pain points:
- An instructor running a program (e.g., a bootcamp, onboarding track, or certification pathway) must enroll students in each constituent course one by one.
- There is no shared space for class-wide announcements, files, or discussions that span multiple courses.
- Students cannot see a unified "program" view in the LMS — they only see a flat list of individual courses.
- Org admins have no way to bundle courses for a specific cohort of learners (e.g., "Q1 2026 New Hire Onboarding") without creating separate organizations or duplicating content.
## Confirmed Decisions
1. **Terminology**: "Class" in both the instructor-facing admin UI and the student-facing LMS.
2. **Auto-enrollment**: Joining a class automatically creates `groupmember` records for every course inside the class. Removing a student from a class does not remove them from individual course groups, preserving their submission and progress data.
3. **Shared materials**: Each class has three shared material surfaces: announcements (instructor-authored posts), a file library, and a community Q&A board.
4. **Many-to-many courses**: A course can belong to multiple classes. Courses and classes are independent entities linked by a join table.
5. **LMS visibility**: Classes are visible to students in the LMS under a dedicated "Classes" section. Students see enrolled classes and can navigate courses from within a class hub page.
6. **Roles**: Class roles mirror the existing system (`ADMIN`/roleId 1, `TUTOR`/roleId 2, `STUDENT`/roleId 3). Class admins and tutors can manage the class; class students get read access to materials.
7. **Class status**: Classes can be `ACTIVE`, `INACTIVE`, or `ARCHIVED`. `INACTIVE` classes are hidden from students in the LMS but preserved for instructors. `ARCHIVED` classes are soft-deleted and hidden everywhere.
8. **Course independence**: Courses in a class remain fully independent. A student enrolled in a class is a regular `groupmember` in each course — existing course pages, progress tracking, submissions, and grading are unchanged.
9. **Community reuse**: The existing `community_question` table gains a nullable `classId` FK. Class-level Q&A behaves identically to course-level community Q&A but scoped to the class.
## Current-State Audit
| Capability | Current State | Notes |
| --- | --- | --- |
| Organization structure | `organization` → `group` (1:1 with `course`) → `course` | No intermediate grouping layer above course |
| Course enrollment | `groupmember` rows per course's `group` | Per-course, no bulk enrollment mechanism |
| Student LMS view | Flat list of enrolled courses under `/lms/mylearning` | No program/class concept |
| Community Q&A | `community_question.courseId` scoped | Reusable with additional `classId` FK |
| Shared file library | Per-course via `assets` table; no org-wide shared files | New `class_file` table needed |
| Announcements | Per-course `course_newsfeed` | New `class_announcement` table needed |
| Org admin navigation | "Courses" section listing all courses | "Classes" nav item needed |
| LMS navigation | Home, My Learning, Exercises, Community, Explore, Settings | "Classes" nav item needed |
| Enrollment service | `enrollInCourse()` in `apps/api/src/services/course/invite.ts` | Must be called per-course when adding class member |
**Key insight**: Because enrollment = `groupmember` rows per course, adding a student to a class is purely a matter of calling the existing enrollment logic for each course in the class within a single transaction. No course-level schema changes are needed.
## Product Goals
1. Org admins and instructors can create a class, add courses to it, and manage class membership from the admin dashboard.
2. Adding a student to a class auto-enrolls them in all courses currently in the class.
3. Adding a new course to an existing class auto-enrolls all current class students in that course.
4. Each class has a shared announcements feed, file library, and community board visible to all class members.
5. Students can see their enrolled classes in the LMS with a unified hub view showing all courses, announcements, and files.
6. Classes without students, and courses outside any class, continue to work exactly as they do today (zero regression).
## Non-Goals (v1)
- Class-level invite links (v1 enrollment is admin-managed; students cannot self-enroll in a class via a token).
- Class-specific exercise deadlines or content overrides (courses are shared as-is).
- Class progress analytics aggregated across all courses (available per-course; cross-course aggregation is a future improvement).
- Removing a student from a class also unenrolling them from individual courses (data preservation is the default).
- Class templates or class cloning.
- Public/discoverable class landing pages (classes are private to org members).
- Billing or pricing at the class level.
- Class-level attendance tracking separate from per-course attendance.
## Data Sources Checked
- `packages/db/src/schema.ts` — all table definitions and constraint patterns
- `apps/api/src/services/course/course.ts` — course creation flow
- `apps/api/src/services/course/invite.ts` — enrollment flow (`enrollInCourse`)
- `apps/api/src/routes/course/course.ts` — course router and sub-router mounting
- `apps/api/src/app.ts` — top-level router registration
- `packages/db/src/queries/course/people.ts` — group member management
- `packages/db/src/queries/group/group.ts` — group/member queries
- `packages/db/src/queries/community/community.ts` — community Q&A pattern
- `apps/dashboard/src/lib/features/lms/` — LMS navigation and pages
- `apps/dashboard/src/lib/features/org/` — org admin navigation
- `apps/dashboard/src/routes/lms/` — LMS SvelteKit routes
- `apps/dashboard/src/routes/courses/` — course admin SvelteKit routes
## Functional Requirements
### 1. Class CRUD (Admin)
- Org admins can create a class with: name (required), description (optional), cover image (optional).
- Classes can be updated (name, description, cover image, status).
- Archiving a class sets `status = 'ARCHIVED'` and hides it from all views. Archived classes can be restored.
- A class belongs to exactly one organization.
- Class names do not need to be unique within an org (unlike course slugs), but should be descriptive.
### 2. Course Management in a Class (Admin)
- Org admins and class tutors can add existing org courses to a class.
- A course can belong to multiple classes simultaneously.
- Adding a course to a class triggers auto-enrollment: all existing `STUDENT` class members are enrolled in the new course (a `groupmember` row is created in the course's `group` for each student not already enrolled).
- Removing a course from a class does not unenroll any students from that course. Their progress and submissions are preserved.
- Courses within a class are ordered (integer `order` field) so instructors can sequence them.
### 3. Class Membership (Admin)
- Org admins can add members to a class by profile ID or email, assigning them a role (ADMIN, TUTOR, or STUDENT).
- Adding a STUDENT member triggers auto-enrollment: a `groupmember` row is created in every course currently in the class (skipping courses the student is already enrolled in; idempotent).
- Adding an ADMIN or TUTOR member does not auto-enroll them in courses (their course-level permissions are managed separately via course membership).
- Removing a member from a class removes the `class_member` row only. Individual `groupmember` rows in courses are left intact, preserving submissions, grades, and progress.
- Batch add: admins can add multiple members at once (array of `{ email, roleId }` pairs).
### 4. Class Announcements (Admin + LMS)
- Class admins and tutors can post announcements to the class feed.
- Announcements have a title and a rich-text body.
- Announcements are visible to all class members (students, tutors, and admins) in both the admin class view and the LMS class hub.
- Students cannot post announcements (read-only for students).
- Announcements are ordered by `createdAt` descending (newest first).
### 5. Class File Library (Admin + LMS)
- Class admins and tutors can upload files to the class file library.
- Each file record stores: name, URL (stored via the existing presigned upload flow), MIME type, size, uploader profile ID.
- All class members can view and download files.
- Only class admins and tutors can delete files.
### 6. Class Community (Admin + LMS)
- Class-level community Q&A reuses the existing `community_question` / `community_answer` infrastructure.
- A nullable `classId` FK is added to `community_question`. Questions with `classId` set are class-scoped (not visible on the course-level community board).
- All class members can post questions and answers in the class community.
- The existing voting, commenting, and answer-acceptance behavior is unchanged.
### 7. LMS — My Classes (Student)
- A "Classes" nav item appears in the LMS sidebar when the student is enrolled in at least one class.
- The `/lms/classes` page lists all classes the student is enrolled in.
- Each class card shows: class name, cover image, number of courses, brief description.
- Clicking a class opens the class hub page.
### 8. LMS — Class Hub (Student)
- The class hub at `/lms/classes/[classId]` is the student's central page for a class.
- It is divided into three tabs or sections:
- **Courses**: All courses in the class, each with the student's individual progress bar and a direct link to the course. Courses are shown in the instructor-defined order.
- **Announcements**: Class announcements in reverse-chronological order (read-only for students).
- **Resources**: The class file library (download only) and a link to the class community board.
- The class community board lives at `/lms/classes/[classId]/community` and behaves identically to the existing course community pages.
### 9. Admin — Classes Section
- "Classes" is added as a top-level nav item in the org admin sidebar, alongside "Courses".
- The `/courses/classes` page lists all org classes with name, course count, student count, and status badge.
- A "Create Class" button opens a creation form.
- Each class links to a class detail view with sub-pages: Overview, Courses, People, Announcements, Files.
### 10. Access Control
- Only `ADMIN` and `TUTOR` role members of a class (or org admins) can manage class content and membership.
- Students can read announcements and files, participate in the community, and view the course list.
- A student who is removed from the org loses access to all class content.
- An `INACTIVE` class is hidden from students in the LMS but all data is preserved.
---
## Technical Design
### Data Model
#### New Enum: `CLASS_STATUS`
```typescript
// packages/db/src/schema.ts
export const classStatus = pgEnum('CLASS_STATUS', ['ACTIVE', 'INACTIVE', 'ARCHIVED']);
```
#### New Table: `class`
```typescript
// packages/db/src/schema.ts
export const lmsClass = pgTable(
'class',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
organizationId: uuid('organization_id').notNull(),
name: varchar().notNull(),
description: text(),
coverImage: text('cover_image'),
status: classStatus().default('ACTIVE').notNull(),
createdByProfileId: uuid('created_by_profile_id')
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'class_organization_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByProfileId],
foreignColumns: [profile.id],
name: 'class_created_by_profile_id_fkey'
}),
index('idx_class_organization_id').on(table.organizationId)
]
);
```
#### New Table: `class_course`
Many-to-many join between class and course, with an instructor-controlled display order.
```typescript
// packages/db/src/schema.ts
export const classCourse = pgTable(
'class_course',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
classId: uuid('class_id').notNull(),
courseId: uuid('course_id').notNull(),
order: integer().default(0).notNull(),
addedAt: timestamp('added_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.classId],
foreignColumns: [lmsClass.id],
name: 'class_course_class_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'class_course_course_id_fkey'
}).onDelete('cascade'),
unique('class_course_class_id_course_id_unique').on(table.classId, table.courseId),
index('idx_class_course_class_id').on(table.classId)
]
);
```
#### New Table: `class_member`
Membership roster for a class. Does not replace `groupmember` — it is a separate layer.
```typescript
// packages/db/src/schema.ts
export const classMember = pgTable(
'class_member',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
classId: uuid('class_id').notNull(),
profileId: uuid('profile_id'),
roleId: integer('role_id').notNull(),
email: text(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.classId],
foreignColumns: [lmsClass.id],
name: 'class_member_class_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'class_member_profile_id_fkey'
}),
foreignKey({
columns: [table.roleId],
foreignColumns: [role.id],
name: 'class_member_role_id_fkey'
}),
unique('class_member_class_id_profile_id_unique').on(table.classId, table.profileId),
index('idx_class_member_class_id').on(table.classId),
index('idx_class_member_profile_id').on(table.profileId)
]
);
```
#### New Table: `class_announcement`
```typescript
// packages/db/src/schema.ts
export const classAnnouncement = pgTable(
'class_announcement',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
classId: uuid('class_id').notNull(),
authorProfileId: uuid('author_profile_id'),
title: varchar().notNull(),
body: text(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
foreignKey({
columns: [table.classId],
foreignColumns: [lmsClass.id],
name: 'class_announcement_class_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.authorProfileId],
foreignColumns: [profile.id],
name: 'class_announcement_author_profile_id_fkey'
}),
index('idx_class_announcement_class_id').on(table.classId)
]
);
```
#### New Table: `class_file`
```typescript
// packages/db/src/schema.ts
export const classFile = pgTable(
'class_file',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
classId: uuid('class_id').notNull(),
uploadedByProfileId: uuid('uploaded_by_profile_id'),
name: varchar().notNull(),
url: text().notNull(),
mimeType: varchar('mime_type'),
size: integer(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.classId],
foreignColumns: [lmsClass.id],
name: 'class_file_class_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.uploadedByProfileId],
foreignColumns: [profile.id],
name: 'class_file_uploaded_by_profile_id_fkey'
}),
index('idx_class_file_class_id').on(table.classId)
]
);
```
#### Modified Table: `community_question`
Add a nullable `classId` FK so community questions can be scoped to a class:
```typescript
// Add to community_question table columns:
classId: uuid('class_id')
// Add to community_question table constraints:
foreignKey({
columns: [table.classId],
foreignColumns: [lmsClass.id],
name: 'community_question_class_id_fkey'
}).onDelete('cascade')
```
When `classId` is set, the question is class-scoped. When `courseId` is set, it is course-scoped. The two are mutually exclusive in practice (enforced at the service layer).
#### Relationship Diagram
```
organization
└── class
├── class_course ──────────────────────→ course
│ (order, addedAt) └── group → groupmember ← (auto-created)
├── class_member ──────────────────────→ profile
│ (roleId: ADMIN/TUTOR/STUDENT)
├── class_announcement
│ (title, body, authorProfileId)
├── class_file
│ (name, url, mimeType, size)
└── community_question (classId set)
└── community_answer
```
### Migration
File: `packages/db/src/migrations/XXXX_add_classes.sql`
```sql
-- 1. Create CLASS_STATUS enum
CREATE TYPE "CLASS_STATUS" AS ENUM ('ACTIVE', 'INACTIVE', 'ARCHIVED');
-- 2. Create class table
CREATE TABLE class (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
name VARCHAR NOT NULL,
description TEXT,
cover_image TEXT,
status "CLASS_STATUS" NOT NULL DEFAULT 'ACTIVE',
created_by_profile_id UUID REFERENCES profile(id)
);
CREATE INDEX idx_class_organization_id ON class(organization_id);
-- 3. Create class_course join table
CREATE TABLE class_course (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE,
course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE,
"order" INTEGER NOT NULL DEFAULT 0,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(class_id, course_id)
);
CREATE INDEX idx_class_course_class_id ON class_course(class_id);
-- 4. Create class_member table
CREATE TABLE class_member (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE,
profile_id UUID REFERENCES profile(id),
role_id INTEGER NOT NULL REFERENCES role(id),
email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(class_id, profile_id)
);
CREATE INDEX idx_class_member_class_id ON class_member(class_id);
CREATE INDEX idx_class_member_profile_id ON class_member(profile_id);
-- 5. Create class_announcement table
CREATE TABLE class_announcement (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE,
author_profile_id UUID REFERENCES profile(id),
title VARCHAR NOT NULL,
body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_class_announcement_class_id ON class_announcement(class_id);
-- 6. Create class_file table
CREATE TABLE class_file (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES class(id) ON DELETE CASCADE,
uploaded_by_profile_id UUID REFERENCES profile(id),
name VARCHAR NOT NULL,
url TEXT NOT NULL,
mime_type VARCHAR,
size INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_class_file_class_id ON class_file(class_id);
-- 7. Add classId to community_question
ALTER TABLE community_question ADD COLUMN class_id UUID REFERENCES class(id) ON DELETE CASCADE;
```
---
## Access Control Logic
Two middleware functions are needed in `apps/api/src/middleware/`:
### `classAccessMiddleware` (read access)
Verifies the caller is either an org admin or a `class_member` for the given `:classId`. Used on all `GET /class/:classId/*` routes.
```typescript
// apps/api/src/middleware/class-access.ts
export async function classAccessMiddleware(c, next) {
const { classId } = c.req.param();
const user = c.get('user')!;
const isMember = await isClassMember(classId, user.profileId);
const isOrgAdmin = await isOrgAdminByClassId(classId, user.profileId);
if (!isMember && !isOrgAdmin) throw new AppError('Forbidden', ErrorCodes.FORBIDDEN, 403);
await next();
}
```
### `classAdminMiddleware` (write access)
Verifies the caller has `roleId` 1 (ADMIN) or 2 (TUTOR) within the class, or is an org admin. Used on all mutating routes.
```typescript
// apps/api/src/middleware/class-admin.ts
export async function classAdminMiddleware(c, next) {
const { classId } = c.req.param();
const user = c.get('user')!;
const role = await getClassMemberRole(classId, user.profileId);
const isOrgAdmin = await isOrgAdminByClassId(classId, user.profileId);
if (!isOrgAdmin && (!role || role > 2)) throw new AppError('Forbidden', ErrorCodes.FORBIDDEN, 403);
await next();
}
```
---
## API and Route Plan
### Validation Layer
File: `packages/utils/src/validation/class/class.ts`
```typescript
import { z } from 'zod';
export const ZCreateClass = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
coverImage: z.string().url().optional()
});
export type TCreateClass = z.infer<typeof ZCreateClass>;
export const ZUpdateClass = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).nullable().optional(),
coverImage: z.string().url().nullable().optional(),
status: z.enum(['ACTIVE', 'INACTIVE', 'ARCHIVED']).optional()
});
export type TUpdateClass = z.infer<typeof ZUpdateClass>;
export const ZAddCourseToClass = z.object({
courseId: z.string().uuid(),
order: z.number().int().min(0).optional()
});
export type TAddCourseToClass = z.infer<typeof ZAddCourseToClass>;
export const ZAddClassMembers = z.object({
members: z.array(
z.object({
email: z.string().email(),
roleId: z.number().int().min(1).max(3)
})
).min(1)
});
export type TAddClassMembers = z.infer<typeof ZAddClassMembers>;
export const ZUpdateClassMember = z.object({
roleId: z.number().int().min(1).max(3)
});
export type TUpdateClassMember = z.infer<typeof ZUpdateClassMember>;
export const ZCreateClassAnnouncement = z.object({
title: z.string().min(1).max(500),
body: z.string().max(10000).optional()
});
export type TCreateClassAnnouncement = z.infer<typeof ZCreateClassAnnouncement>;
export const ZUpdateClassAnnouncement = z.object({
title: z.string().min(1).max(500).optional(),
body: z.string().max(10000).nullable().optional()
});
export type TUpdateClassAnnouncement = z.infer<typeof ZUpdateClassAnnouncement>;
export const ZAddClassFile = z.object({
name: z.string().min(1).max(255),
url: z.string().url(),
mimeType: z.string().optional(),
size: z.number().int().positive().optional()
});
export type TAddClassFile = z.infer<typeof ZAddClassFile>;
```
Export from: `packages/utils/src/validation/class/index.ts` and re-export from `packages/utils/src/validation/index.ts`.
### Query Layer
File: `packages/db/src/queries/class/class.ts`
| Function | Description |
| --- | --- |
| `createClass(data)` | Insert into `class`, return created row |
| `getClassById(classId)` | Single class with member count and course count |
| `getClassesByOrg(orgId)` | All non-archived classes for org, ordered by `createdAt` desc |
| `updateClass(classId, data)` | Update name, description, coverImage, status |
| `getClassMemberRole(classId, profileId)` | Return `roleId` for the given profile in the class, or null |
| `isClassMember(classId, profileId)` | Boolean check |
| `isOrgAdminByClassId(classId, profileId)` | Joins `class → organization → organizationmember` to check admin role |
| `addClassMember(classId, profileId, roleId, email)` | Insert `class_member` row (upsert on conflict) |
| `removeClassMember(classId, memberId)` | Delete `class_member` by `id` |
| `getClassMembers(classId)` | All members joined to `profile` for display |
| `getEnrolledClassesByProfile(orgId, profileId)` | All `ACTIVE` classes where profile is a class member, for LMS |
| `addCourseToClass(classId, courseId, order)` | Insert `class_course` row |
| `removeCourseFromClass(classId, courseId)` | Delete `class_course` row |
| `getCoursesByClass(classId)` | All courses in class ordered by `order`, joined to `course` |
| `getStudentMemberIdsByClass(classId)` | All `profileId` values with `roleId = 3` in class (for auto-enrollment) |
| `createClassAnnouncement(data)` | Insert `class_announcement` |
| `getClassAnnouncements(classId)` | All announcements ordered by `createdAt` desc, joined to `profile` |
| `updateClassAnnouncement(announcementId, data)` | Update title/body |
| `deleteClassAnnouncement(announcementId)` | Delete row |
| `addClassFile(data)` | Insert `class_file` |
| `getClassFiles(classId)` | All files ordered by `createdAt` desc |
| `deleteClassFile(fileId)` | Delete `class_file` by `id` |
Export from: `packages/db/src/queries/class/index.ts`
### Service Layer
File: `apps/api/src/services/class/class.ts`
#### `createClass(orgId, profileId, data: TCreateClass)`
- Insert `class` row.
- Add creator as `class_member` with `roleId = 1` (ADMIN).
- Return the new class.
#### `addCourseToClass(classId, courseId)`
- Verify class exists and course belongs to the same org.
- Insert `class_course` row (error if already linked).
- Fetch all current STUDENT class members (`getStudentMemberIdsByClass`).
- For each student profile, look up their `groupmember` status in the course's group; if not already enrolled, call `enrollInCourse` (or insert `groupmember` directly if the course is free and no invite is needed).
- All writes in a single transaction.
- Return updated course list for the class.
#### `removeCourseFromClass(classId, courseId)`
- Delete `class_course` row.
- No changes to `groupmember` records.
- Return updated course list.
#### `addMembersToClass(classId, members: TAddClassMembers['members'])`
Transaction:
1. For each member in the batch, resolve `profileId` from `email` (look up `profile` by email, create if none exists as an invited-but-unregistered user following the same pattern as `addGroupMember`).
2. Insert `class_member` row (skip if already a member of the class).
3. If `roleId = 3` (STUDENT), fetch all courses in the class (`getCoursesByClass`). For each course, check if the student is already a `groupmember`; if not, insert a `groupmember` row with `roleId = 3`.
#### `removeMemberFromClass(classId, memberId)`
- Delete `class_member` row by `id`.
- Leave all `groupmember` rows in individual courses intact.
#### `createAnnouncement(classId, profileId, data: TCreateClassAnnouncement)`
- Verify caller is class admin or tutor.
- Insert `class_announcement` row.
- Return new announcement.
#### `addFile(classId, profileId, data: TAddClassFile)`
- Insert `class_file` row.
- Return new file record.
#### `deleteFile(fileId, profileId)`
- Verify caller is class admin or tutor.
- Delete `class_file` row.
### Route Layer
File: `apps/api/src/routes/class/class.ts`
All routes require `authMiddleware`. Routes scoped to a `:classId` also apply `classAccessMiddleware` (read) or `classAdminMiddleware` (write) as noted.
| Method | Path | Middleware | Description |
| --- | --- | --- | --- |
| `GET` | `/class` | `authMiddleware` | List org's classes (org admin sees all; tutors/students see their own) |
| `POST` | `/class` | `authMiddleware` + orgAdmin | Create class |
| `GET` | `/class/:classId` | `classAccessMiddleware` | Get class with member/course counts |
| `PUT` | `/class/:classId` | `classAdminMiddleware` | Update class name, description, cover, status |
| `DELETE` | `/class/:classId` | orgAdmin | Archive class (`status = 'ARCHIVED'`) |
| `GET` | `/class/:classId/courses` | `classAccessMiddleware` | List courses in class (ordered) |
| `POST` | `/class/:classId/courses` | `classAdminMiddleware` | Add course to class + auto-enroll students |
| `DELETE` | `/class/:classId/courses/:courseId` | `classAdminMiddleware` | Remove course from class |
| `GET` | `/class/:classId/members` | `classAdminMiddleware` | List class members with profile info |
| `POST` | `/class/:classId/members` | `classAdminMiddleware` | Batch-add members + auto-enroll students |
| `PUT` | `/class/:classId/members/:memberId` | `classAdminMiddleware` | Update member role |
| `DELETE` | `/class/:classId/members/:memberId` | `classAdminMiddleware` | Remove member from class |
| `GET` | `/class/:classId/announcements` | `classAccessMiddleware` | List announcements |
| `POST` | `/class/:classId/announcements` | `classAdminMiddleware` | Create announcement |
| `PUT` | `/class/:classId/announcements/:announcementId` | `classAdminMiddleware` | Update announcement |
| `DELETE` | `/class/:classId/announcements/:announcementId` | `classAdminMiddleware` | Delete announcement |
| `GET` | `/class/:classId/files` | `classAccessMiddleware` | List files |
| `POST` | `/class/:classId/files` | `classAdminMiddleware` | Add file record |
| `DELETE` | `/class/:classId/files/:fileId` | `classAdminMiddleware` | Delete file |
#### Route Registration
```typescript
// apps/api/src/routes/class/index.ts
export * from './class';
// apps/api/src/app.ts
import { classRouter } from '@api/routes/class';
export const app = new Hono()
// ... existing routes ...
.route('/class', classRouter);
```
#### LMS Enrolled Classes Route
Add to `apps/api/src/routes/organization/organization.ts`:
```
GET /organization/classes/enrolled
```
Returns all `ACTIVE` classes where the current user is a STUDENT `class_member`, including the course list with per-course progress. Used by the LMS "My Classes" and "Class Hub" pages.
#### Build Verification
```bash
pnpm --filter @cio/utils build
pnpm --filter @cio/db build
pnpm --filter @cio/api build
```
---
## Frontend Plan (Dashboard)
### Request Types
File: `apps/dashboard/src/lib/features/class/utils/types.ts`
```typescript
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListClassesRequest = typeof classroomio.class.$get;
export type CreateClassRequest = typeof classroomio.class.$post;
export type GetClassRequest = typeof classroomio.class[':classId']['$get'];
export type UpdateClassRequest = typeof classroomio.class[':classId']['$put'];
export type ListClassCoursesRequest = typeof classroomio.class[':classId']['courses']['$get'];
export type AddCourseToClassRequest = typeof classroomio.class[':classId']['courses']['$post'];
export type ListClassMembersRequest = typeof classroomio.class[':classId']['members']['$get'];
export type AddClassMembersRequest = typeof classroomio.class[':classId']['members']['$post'];
export type ListAnnouncementsRequest = typeof classroomio.class[':classId']['announcements']['$get'];
export type CreateAnnouncementRequest = typeof classroomio.class[':classId']['announcements']['$post'];
export type ListClassFilesRequest = typeof classroomio.class[':classId']['files']['$get'];
export type ListClassesSuccess = Extract<InferResponseType<ListClassesRequest>, { success: true }>;
export type ClassItem = ListClassesSuccess['data'][number];
export type GetClassSuccess = Extract<InferResponseType<GetClassRequest>, { success: true }>;
export type ClassDetail = GetClassSuccess['data'];
export type ListClassCoursesSuccess = Extract<InferResponseType<ListClassCoursesRequest>, { success: true }>;
export type ClassCourse = ListClassCoursesSuccess['data'][number];
export type ListClassMembersSuccess = Extract<InferResponseType<ListClassMembersRequest>, { success: true }>;
export type ClassMember = ListClassMembersSuccess['data'][number];
export type ListAnnouncementsSuccess = Extract<InferResponseType<ListAnnouncementsRequest>, { success: true }>;
export type ClassAnnouncement = ListAnnouncementsSuccess['data'][number];
export type ListClassFilesSuccess = Extract<InferResponseType<ListClassFilesRequest>, { success: true }>;
export type ClassFile = ListClassFilesSuccess['data'][number];
// LMS enrolled classes
export type EnrolledClassesRequest = typeof classroomio.organization.classes.enrolled['$get'];
export type EnrolledClassesSuccess = Extract<InferResponseType<EnrolledClassesRequest>, { success: true }>;
export type EnrolledClass = EnrolledClassesSuccess['data'][number];
```
### API Class
File: `apps/dashboard/src/lib/features/class/api/class.svelte.ts`
```typescript
class ClassApi extends BaseApiWithErrors {
classes = $state<ClassItem[]>([]);
currentClass = $state<ClassDetail | null>(null);
courses = $state<ClassCourse[]>([]);
members = $state<ClassMember[]>([]);
announcements = $state<ClassAnnouncement[]>([]);
files = $state<ClassFile[]>([]);
loading = $state(false);
async list() { /* GET /class */ }
async get(classId: string) { /* GET /class/:classId */ }
async create(data: TCreateClass) { /* POST /class */ }
async update(classId: string, data: TUpdateClass) { /* PUT /class/:classId */ }
async archive(classId: string) { /* DELETE /class/:classId */ }
async listCourses(classId: string) { /* GET /class/:classId/courses */ }
async addCourse(classId: string, data: TAddCourseToClass) { /* POST /class/:classId/courses */ }
async removeCourse(classId: string, courseId: string) { /* DELETE /class/:classId/courses/:courseId */ }
async listMembers(classId: string) { /* GET /class/:classId/members */ }
async addMembers(classId: string, data: TAddClassMembers) { /* POST /class/:classId/members */ }
async removeMember(classId: string, memberId: string) { /* DELETE /class/:classId/members/:memberId */ }
async listAnnouncements(classId: string) { /* GET /class/:classId/announcements */ }
async createAnnouncement(classId: string, data: TCreateClassAnnouncement) { /* POST */ }
async deleteAnnouncement(classId: string, announcementId: string) { /* DELETE */ }
async listFiles(classId: string) { /* GET /class/:classId/files */ }
async addFile(classId: string, data: TAddClassFile) { /* POST /class/:classId/files */ }
async deleteFile(classId: string, fileId: string) { /* DELETE /class/:classId/files/:fileId */ }
}
export const classApi = new ClassApi();
```
### LMS API Class
File: `apps/dashboard/src/lib/features/lms/api/class.svelte.ts`
```typescript
class LmsClassApi extends BaseApiWithErrors {
enrolledClasses = $state<EnrolledClass[]>([]);
loading = $state(false);
async listEnrolled() { /* GET /organization/classes/enrolled */ }
}
export const lmsClassApi = new LmsClassApi();
```
### Admin Pages
| Route | File | Purpose |
| --- | --- | --- |
| `/courses/classes` | `apps/dashboard/src/routes/courses/classes/+page.svelte` | List all org classes |
| `/courses/classes/new` | `apps/dashboard/src/routes/courses/classes/new/+page.svelte` | Create class form |
| `/courses/classes/[classId]` | `apps/dashboard/src/routes/courses/classes/[classId]/+page.svelte` | Class overview |
| `/courses/classes/[classId]/courses` | `.../courses/+page.svelte` | Manage courses |
| `/courses/classes/[classId]/people` | `.../people/+page.svelte` | Manage members |
| `/courses/classes/[classId]/announcements` | `.../announcements/+page.svelte` | Manage announcements |
| `/courses/classes/[classId]/files` | `.../files/+page.svelte` | File library management |
Feature pages live in: `apps/dashboard/src/lib/features/class/pages/`
### LMS Pages
| Route | File | Purpose |
| --- | --- | --- |
| `/lms/classes` | `apps/dashboard/src/routes/lms/classes/+page.svelte` | My enrolled classes |
| `/lms/classes/[classId]` | `apps/dashboard/src/routes/lms/classes/[classId]/+page.svelte` | Class hub (courses + announcements + files) |
| `/lms/classes/[classId]/community` | `.../community/+page.svelte` | Class community Q&A |
Feature pages live in: `apps/dashboard/src/lib/features/lms/pages/class/`
### Navigation Updates
**Org admin sidebar** (`apps/dashboard/src/lib/features/org/`):
- Add "Classes" nav item at the same level as "Courses", linking to `/courses/classes`.
- Use a suitable icon (e.g., a group/layers icon).
**LMS sidebar** (`apps/dashboard/src/lib/features/lms/`):
- Add "Classes" nav item (shown conditionally: only when `enrolledClasses.length > 0`).
- Position between "My Learning" and "Exercises".
### Translations
File: `apps/dashboard/src/lib/utils/translations/en.json`
Add keys under `class`:
```json
{
"class": {
"title": "Classes",
"my_classes": "My Classes",
"create": "Create Class",
"create_description": "Group multiple courses and materials under one class for your students.",
"name": "Class Name",
"description": "Description",
"cover_image": "Cover Image",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"archived": "Archived",
"courses": "Courses",
"people": "People",
"announcements": "Announcements",
"files": "Files",
"community": "Community",
"add_course": "Add Course",
"remove_course": "Remove Course",
"add_members": "Add Members",
"remove_member": "Remove Member",
"course_count": "{count} courses",
"student_count": "{count} students",
"new_announcement": "New Announcement",
"announcement_title": "Announcement Title",
"announcement_body": "Write your announcement...",
"upload_file": "Upload File",
"no_classes": "No classes yet. Create one to get started.",
"no_courses": "No courses in this class yet.",
"no_members": "No members yet. Add students or tutors.",
"no_announcements": "No announcements yet.",
"no_files": "No files uploaded yet.",
"archive_confirm": "Archiving this class will hide it from all views. Are you sure?",
"auto_enroll_note": "Adding this course will enroll all current class students in it.",
"remove_course_note": "Removing this course will not unenroll any students.",
"remove_member_note": "Removing this member will not unenroll them from individual courses."
}
}
```
### Frontend Build Verification
```bash
pnpm --filter @cio/dashboard build
```
---
## Implementation Order
### Phase 1: Database + Schema
1. Add `classStatus` enum to `packages/db/src/schema.ts`.
2. Add `lmsClass`, `classCourse`, `classMember`, `classAnnouncement`, `classFile` tables to schema.
3. Add `classId` FK column to `communityQuestion` table in schema.
4. Write and apply migration SQL: `packages/db/src/migrations/XXXX_add_classes.sql`.
5. Run `pnpm --filter @cio/db build`.
### Phase 2: Validation + Queries
1. Create `packages/utils/src/validation/class/class.ts` with all Zod schemas.
2. Export from `packages/utils/src/validation/class/index.ts` and re-export from `packages/utils/src/validation/index.ts`.
3. Run `pnpm --filter @cio/utils build`.
4. Create `packages/db/src/queries/class/class.ts` with all query functions.
5. Create `packages/db/src/queries/class/index.ts` and export.
6. Run `pnpm --filter @cio/db build`.
### Phase 3: Service + Routes
1. Create `apps/api/src/services/class/class.ts` with all service functions (auto-enrollment transactions are the most complex part).
2. Create access control utilities: `apps/api/src/middleware/class-access.ts` and `apps/api/src/middleware/class-admin.ts`.
3. Add new error codes to `apps/api/src/utils/errors.ts`: `CLASS_NOT_FOUND`, `CLASS_FORBIDDEN`, `COURSE_ALREADY_IN_CLASS`, `MEMBER_ALREADY_IN_CLASS`.
4. Create `apps/api/src/routes/class/class.ts` with all route handlers.
5. Create `apps/api/src/routes/class/index.ts` and register in `apps/api/src/app.ts`.
6. Add `GET /organization/classes/enrolled` to the organization router.
7. Run `pnpm --filter @cio/api build`.
### Phase 4: Admin Frontend
1. Add request/response types to `apps/dashboard/src/lib/features/class/utils/types.ts`.
2. Create `apps/dashboard/src/lib/features/class/api/class.svelte.ts` API class.
3. Add translation keys to `en.json`.
4. Create admin feature pages under `apps/dashboard/src/lib/features/class/pages/`.
5. Create SvelteKit route files under `apps/dashboard/src/routes/courses/classes/`.
6. Add "Classes" nav item to the org admin sidebar.
7. Run `pnpm --filter @cio/dashboard build`.
### Phase 5: LMS Frontend
1. Create `apps/dashboard/src/lib/features/lms/api/class.svelte.ts`.
2. Create LMS feature pages under `apps/dashboard/src/lib/features/lms/pages/class/`.
3. Create SvelteKit route files under `apps/dashboard/src/routes/lms/classes/`.
4. Add "Classes" nav item to the LMS sidebar (conditional on having enrolled classes).
5. Run `pnpm --filter @cio/dashboard build`.
---
## Acceptance Criteria
1. Org admin can create a class, add courses to it, and add students — all from the admin dashboard.
2. When a student is added to a class, `groupmember` rows are automatically created for every course in the class.
3. When a course is added to an existing class that already has students, all those students are enrolled in the new course.
4. Removing a student from a class does not remove their `groupmember` records in individual courses.
5. Removing a course from a class does not unenroll any students.
6. Students see a "Classes" section in the LMS sidebar (only when enrolled in at least one class).
7. The class hub page shows all courses with per-course progress bars, the announcements feed, and the file library.
8. Class admins and tutors can post announcements and upload files; students can only view them.
9. Class community Q&A is scoped to the class and does not appear on course community boards.
10. `INACTIVE` classes are hidden from the student LMS but remain fully accessible to admins and tutors.
11. `ARCHIVED` classes are hidden from all views.
12. Courses that are not in any class, and courses whose students were enrolled independently, continue to work exactly as they do today (zero regression on existing `groupmember` and course data).
13. All user-facing strings use translation keys (no hardcoded English).
14. `pnpm --filter @cio/api build` and `pnpm --filter @cio/dashboard build` pass after implementation.
---
## Risks and Mitigations
- **Risk**: Auto-enrollment transaction for large classes (hundreds of students, tens of courses) times out or partially fails.
- **Mitigation**: Process auto-enrollment in the service layer using a single transaction with batch inserts (insert multiple `groupmember` rows in one query using Drizzle's batch insert). For very large classes (> 500 students), offload to a background job via the existing email job queue or a new async task runner, and return a `202 Accepted` with a polling endpoint.
- **Risk**: A student is already enrolled in a course independently before being added to a class. Duplicate `groupmember` rows violate the `(groupId, profileId)` unique constraint.
- **Mitigation**: The enrollment logic in `addMembersToClass` must check for existing membership before inserting. Use `INSERT ... ON CONFLICT DO NOTHING` or check with `isUserCourseMember` before inserting. This is already idempotent in the existing enrollment service.
- **Risk**: The `community_question.classId` column causes existing community queries to break if they don't filter by `classId IS NULL` for course-scoped questions.
- **Mitigation**: Add `classId IS NULL` to all existing community query filters where `courseId` is used. The column is nullable so existing rows default to NULL and behavior is unchanged. Update `packages/db/src/queries/community/community.ts` alongside the schema migration.
- **Risk**: Removing a course from a class causes confusion: students are still enrolled in the course but it no longer appears in the class hub.
- **Mitigation**: Show a confirmation dialog in the admin UI with the note: "Removing this course will not unenroll any students. They will still have access to it via My Learning." Use the `class.remove_course_note` translation key already specified.
- **Risk**: Org nav gets crowded with a new "Classes" top-level item.
- **Mitigation**: Place "Classes" directly below "Courses" in the sidebar. Consider grouping them under a "Learning" section header if more items are added in future.
+979
View File
@@ -0,0 +1,979 @@
# Compliance Training Platform PRD
## Status
- Draft
## Date
- February 21, 2026
## Purpose
Transform ClassroomIO into the go-to platform for companies that need to deliver, track, and prove compliance training for their employees. This PRD defines features for organizations in regulated industries (healthcare, finance, manufacturing, government) who must ensure their workforce stays compliant with regulations.
---
## 1. Executive Summary
### The Problem
Companies in regulated industries struggle to:
- **Assign** the right training to the right people based on their role/location
- **Track** who completed training on time and who is overdue
- **Remind** employees before deadlines and escalate to managers
- **Prove** compliance to auditors with tamper-proof records
- **Renew** certifications automatically when they expire
### Target Customers
| Industry | Example Use Cases |
|----------|-------------------|
| Healthcare | HIPAA training, patient safety, infection control, medical device handling |
| Financial Services | Anti-money laundering, fraud prevention, data privacy, trading compliance |
| Manufacturing | OSHA safety, quality control (ISO), hazardous materials handling |
| Government | Security clearance, ethics training, procurement rules, FISMA |
| Education | FERPA, Title IX, campus safety, student data handling |
| Retail/Hospitality | Food safety, anti-harassment, PCI-DSS, workplace safety |
### Solution Overview
ClassroomIO becomes a **Compliance Training Management System** that helps organizations:
1. **Deploy** compliance training programs efficiently
2. **Automate** assignment, reminders, and renewals
3. **Monitor** compliance status in real-time
4. **Report** audit-ready evidence instantly
5. **Scale** across thousands of employees and multiple locations
---
## 2. Current State vs. Required Features
### ✅ What ClassroomIO Already Does Well
| Feature | Current Capability | Compliance Gap |
|---------|-------------------|----------------|
| Course Creation | Rich content editor, videos, documents | ✅ Good enough |
| Assessments | Basic exercises with grading | ⚠️ Need more question types & pass/fail rules |
| Certificates | Generate PDF certificates | ⚠️ Need expiration, renewal, revocation |
| User Management | Invite students, track progress | ⚠️ Need bulk assignment by role/department |
| SSO | OIDC/SAML integration | ✅ Enterprise-ready |
| Basic Reporting | Course completion stats | ❌ Need compliance-specific dashboards |
### ❌ Critical Missing Features for Compliance Training
| Missing Feature | Why It Matters | Customer Pain |
|-----------------|----------------|---------------|
| **Mandatory Training Assignment** | Compliance training isn't optional - everyone in a role MUST complete it | Currently can only invite, can't enforce |
| **Recurring/Refresh Training** | Many regulations require annual/quarterly retraining | No automated renewal system |
| **Compliance Dashboard** | See who is compliant, who is overdue, by department/role | No bird's-eye view for compliance officers |
| **Policy Acknowledgment** | Track who read and agreed to policies | No policy attestation workflow |
| **Audit Trail** | Prove "who did what when" to auditors | No immutable activity log |
| **Automated Reminders** | Notify employees before deadlines, escalate to managers | Manual follow-up is unsustainable |
| **Evidence Export** | One-click export of training records for auditors | Manual report generation takes hours |
---
## 3. Feature Requirements
### 3.1 Compliance Training Programs (The Container)
#### Purpose
A "Program" groups related training requirements (e.g., "Annual HIPAA Compliance" or "New Hire Safety Training"). This is the top-level entity compliance officers manage.
#### Requirements
**CP-1: Program Management**
- Create programs with title, description, and regulatory framework (e.g., "HIPAA", "OSHA", "SOX")
- Set program status: draft, active, archived
- Assign program owner (compliance officer responsible)
- Add custom fields for internal tracking (department codes, cost centers, etc.)
**CP-2: Program Content**
- Link multiple courses/lessons to a program (e.g., HIPAA includes: Basics course, Security course, Quiz)
- Set completion rules:
- "Complete all courses"
- "Complete any 2 of 3 courses"
- "Complete Course A AND (Course B OR Course C)"
- Set passing criteria: minimum score, attempt limits, time between retakes
**CP-3: Program Assignment Rules**
- Assign by: role, department, location, employment type, custom attributes
- Examples:
- "All nurses in California hospitals"
- "All new hires in first 30 days"
- "All employees with security clearance Level 2+"
- Support exclusions (e.g., "All managers EXCEPT contractors")
**CP-4: Program Schedule**
- One-time assignment (e.g., new hire training)
- Recurring: every N months/years from completion date OR fixed date
- Grace period: days after due date before marked overdue
- Prerequisites: must complete Program X before starting Program Y
#### Database Schema
```sql
-- Compliance training programs
CREATE TABLE compliance_program (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
title VARCHAR NOT NULL,
description TEXT,
framework VARCHAR, -- 'HIPAA', 'OSHA', 'SOX', 'GDPR', 'Custom'
status VARCHAR DEFAULT 'draft', -- 'draft', 'active', 'archived'
owner_id UUID REFERENCES profile(id),
completion_rule JSONB NOT NULL DEFAULT '{"type": "all"}', -- { type: 'all' | 'any' | 'minimum', count: number }
passing_criteria JSONB NOT NULL DEFAULT '{"minScore": 80, "maxAttempts": 3}',
schedule_config JSONB NOT NULL DEFAULT '{}', -- { type: 'once' | 'recurring', intervalMonths: 12, graceDays: 7 }
custom_fields JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Program content (courses/exercises linked to program)
CREATE TABLE compliance_program_content (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
program_id UUID NOT NULL REFERENCES compliance_program(id),
content_type VARCHAR NOT NULL, -- 'course', 'exercise', 'lesson'
content_id UUID NOT NULL,
is_required BOOLEAN DEFAULT true,
order_index INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Assignment rules (who gets this program)
CREATE TABLE compliance_program_assignment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
program_id UUID NOT NULL REFERENCES compliance_program(id),
rule_type VARCHAR NOT NULL, -- 'role', 'group', 'location', 'attribute', 'all'
rule_value JSONB NOT NULL, -- { roleIds: [], groupIds: [], location: '', attributes: {} }
is_exclusion BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
---
### 3.2 Learner Assignments & Tracking
#### Purpose
Track each learner's obligation, progress, and completion status for every program they're assigned.
#### Requirements
**LA-1: Assignment Lifecycle**
- **Assigned**: User meets assignment rule criteria → creates assignment
- **In Progress**: User started but not completed
- **Completed**: User met all completion rules
- **Overdue**: Past due date + grace period
- **Waived**: Admin exempted user (with reason and expiration)
- **Expired**: Previous completion past validity period → needs renewal
**LA-2: Assignment Attributes**
- Assigned date, due date, completed date
- Current status and status history with timestamps
- Attempts count, best score, time spent
- Certificate issued (if applicable)
- Reminder history (when reminders were sent)
**LA-3: Bulk Operations**
- Bulk assign to users matching criteria
- Bulk extend due dates (e.g., grant extension to entire department)
- Bulk waive with reason
- Bulk re-enroll (force retake even if completed)
**LA-4: Self-Service Portal (for Learners)**
- "My Compliance" dashboard showing all assigned programs
- Visual indicators: due soon (yellow), overdue (red), completed (green)
- Download certificates for completed programs
- See upcoming renewals
#### Database Schema
```sql
-- Individual learner assignments
CREATE TABLE compliance_assignment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
program_id UUID NOT NULL REFERENCES compliance_program(id),
user_id UUID NOT NULL REFERENCES user(id),
-- Assignment details
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
due_date TIMESTAMPTZ NOT NULL,
grace_period_days INTEGER DEFAULT 0,
-- Status tracking
status VARCHAR NOT NULL DEFAULT 'assigned', -- 'assigned', 'in_progress', 'completed', 'overdue', 'waived', 'expired'
status_changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status_history JSONB DEFAULT '[]', -- [{ status, timestamp, reason }]
-- Progress
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
progress_percent INTEGER DEFAULT 0,
-- Results
attempts INTEGER DEFAULT 0,
best_score INTEGER,
passing_score INTEGER,
time_spent_minutes INTEGER,
-- Certificate
certificate_id UUID,
-- Waiver (if applicable)
waived_at TIMESTAMPTZ,
waived_by UUID REFERENCES profile(id),
waiver_reason TEXT,
waiver_expires_at TIMESTAMPTZ,
-- For recurring programs
is_renewal BOOLEAN DEFAULT false,
previous_assignment_id UUID REFERENCES compliance_assignment(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(program_id, user_id, is_renewal) -- One active assignment per program per user
);
-- Assignment content progress (track individual courses within program)
CREATE TABLE compliance_assignment_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id UUID NOT NULL REFERENCES compliance_assignment(id),
content_type VARCHAR NOT NULL,
content_id UUID NOT NULL,
status VARCHAR NOT NULL DEFAULT 'not_started', -- 'not_started', 'in_progress', 'completed', 'failed'
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
score INTEGER,
attempts INTEGER DEFAULT 0,
time_spent_minutes INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
---
### 3.3 Automated Notifications & Escalations
#### Purpose
Ensure employees complete training on time without manual follow-up from compliance teams.
#### Requirements
**NE-1: Reminder Schedule**
- Configurable reminder schedule per program (e.g., 30, 14, 7, 1 days before due)
- Customizable email templates per reminder stage
- Include: program name, due date, progress, direct link to training
**NE-2: Escalation Paths**
- **Level 1**: Reminder to learner
- **Level 2**: Notify learner + CC manager (when overdue)
- **Level 3**: Escalate to compliance admin + learner's skip-level manager
- **Level 4**: Flag for HR/executive review
**NE-3: Manager Notifications**
- Weekly digest of team's compliance status
- Immediate alert when direct report goes overdue
- Dashboard showing team compliance rate
**NE-4: Compliance Team Alerts**
- Daily summary of new overdue assignments
- Alert when large group approaches deadline (e.g., "50 employees due in 3 days")
- System health alerts (failed email deliveries, sync errors)
**NE-5: Notification Preferences**
- Email, in-app notification, or both
- SMS for critical alerts (optional add-on)
- Digest mode vs. immediate notifications
#### Database Schema
```sql
-- Notification templates
CREATE TABLE compliance_notification_template (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
name VARCHAR NOT NULL,
trigger_type VARCHAR NOT NULL, -- 'reminder', 'overdue', 'escalation', 'completion'
trigger_days INTEGER, -- days before/after event (negative = before, positive = after)
subject_template VARCHAR NOT NULL,
body_template TEXT NOT NULL,
recipients VARCHAR[] NOT NULL, -- ['learner', 'manager', 'compliance_admin']
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Notification queue and history
CREATE TABLE compliance_notification (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id UUID NOT NULL REFERENCES compliance_assignment(id),
template_id UUID REFERENCES compliance_notification_template(id),
notification_type VARCHAR NOT NULL,
recipient_type VARCHAR NOT NULL, -- 'learner', 'manager', 'admin'
recipient_id UUID NOT NULL,
recipient_email VARCHAR NOT NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
status VARCHAR NOT NULL DEFAULT 'pending', -- 'pending', 'sent', 'delivered', 'failed', 'bounced'
error_message TEXT,
opened_at TIMESTAMPTZ,
clicked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Notification schedule (tracks what reminders are due)
CREATE TABLE compliance_notification_schedule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id UUID NOT NULL REFERENCES compliance_assignment(id),
reminder_type VARCHAR NOT NULL, -- '30_days', '14_days', '7_days', '1_day', 'overdue', 'escalation'
scheduled_at TIMESTAMPTZ NOT NULL,
is_sent BOOLEAN DEFAULT false,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
---
### 3.4 Policy Attestation (Read & Acknowledge)
#### Purpose
Not all compliance is "training" - sometimes employees just need to read and acknowledge a policy. Track who has read and agreed to important documents.
#### Requirements
**PA-1: Policy Management**
- Upload policy documents (PDF, Word, HTML)
- Version control with version numbers and effective dates
- Categorize (Security, HR, Safety, Code of Conduct, etc.)
- Set required reading time (e.g., "Must view for at least 5 minutes")
**PA-2: Attestation Workflow**
1. Employee receives notification of new/updated policy
2. Employee opens and reads the document (viewer tracks time)
3. Employee checks "I have read and understand this policy"
4. Employee digitally "signs" with name, date, employee ID
5. System records: user, policy version, timestamp, IP, time spent reading
**PA-3: Attestation Tracking**
- Who has attested to which version
- Who hasn't attested (and how overdue)
- Bulk attestation for initial rollout (with verification)
- Re-attestation required when policy version changes
**PA-4: Attestation Certificate**
- Generate proof of attestation per employee
- Include: policy name, version, attestation date, digital signature hash
#### Database Schema
```sql
-- Policy documents
CREATE TABLE compliance_policy (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
title VARCHAR NOT NULL,
description TEXT,
category VARCHAR NOT NULL, -- 'security', 'hr', 'safety', 'conduct', 'privacy'
status VARCHAR DEFAULT 'draft', -- 'draft', 'active', 'archived'
requires_reading_time_seconds INTEGER DEFAULT 0,
created_by UUID NOT NULL REFERENCES profile(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Policy versions
CREATE TABLE compliance_policy_version (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_id UUID NOT NULL REFERENCES compliance_policy(id),
version_number INTEGER NOT NULL,
content_url TEXT NOT NULL, -- S3/MinIO URL
content_hash TEXT NOT NULL,
effective_date TIMESTAMPTZ NOT NULL,
requires_reattestation BOOLEAN DEFAULT true,
created_by UUID NOT NULL REFERENCES profile(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(policy_id, version_number)
);
-- Who needs to attest to which policy version
CREATE TABLE compliance_policy_assignment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_version_id UUID NOT NULL REFERENCES compliance_policy_version(id),
assigned_to_type VARCHAR NOT NULL, -- 'role', 'group', 'department', 'all'
assigned_to_id UUID,
due_date TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Attestation records (immutable)
CREATE TABLE compliance_policy_attestation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_version_id UUID NOT NULL REFERENCES compliance_policy_version(id),
user_id UUID NOT NULL REFERENCES user(id),
attested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
time_spent_seconds INTEGER,
digital_signature VARCHAR NOT NULL, -- hash of user+policy+timestamp
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(policy_version_id, user_id)
);
```
---
### 3.5 Certificates with Lifecycle Management
#### Purpose
Issue certificates that expire, track renewals, and maintain a complete history for audit purposes.
#### Requirements
**CE-1: Certificate Templates**
- Design certificate templates per program
- Include: recipient name, program name, completion date, expiration date, certificate ID, QR code for verification
- Custom fields (CE credits, accreditation body, instructor name)
**CE-2: Certificate Issuance**
- Auto-issue upon program completion
- Manual issue by admin (for external training imported into system)
- Bulk issue for historical data import
**CE-3: Certificate Lifecycle**
- **Valid**: Currently valid certificate
- **Expiring Soon**: Within renewal window
- **Expired**: Past expiration date
- **Revoked**: Invalidated (with reason and revoked_by)
- **Renewed**: Replaced by newer certificate (link to new cert)
**CE-4: Certificate Verification**
- Public verification page (for external auditors)
- QR code on certificate links to verification
- Shows: valid/invalid/revoked status, original issue details
**CE-5: Certificate History**
- Complete chain: Original → Renewal 1 → Renewal 2 → Current
- Never delete - maintain full history even after revocation
#### Database Schema
```sql
-- Certificate definitions (linked to programs)
CREATE TABLE compliance_certificate_definition (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
program_id UUID NOT NULL REFERENCES compliance_program(id),
name VARCHAR NOT NULL,
template_url TEXT NOT NULL, -- S3/MinIO URL to template file
validity_period_months INTEGER NOT NULL, -- 0 = no expiration
renewal_window_days INTEGER DEFAULT 30,
ce_credits DECIMAL(4,2), -- continuing education credits
accreditation_body VARCHAR,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Issued certificates
CREATE TABLE compliance_certificate (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
certificate_number VARCHAR UNIQUE NOT NULL, -- human-readable number
definition_id UUID NOT NULL REFERENCES compliance_certificate_definition(id),
user_id UUID NOT NULL REFERENCES user(id),
assignment_id UUID NOT NULL REFERENCES compliance_assignment(id),
-- Certificate data
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
issued_by UUID NOT NULL REFERENCES profile(id),
expires_at TIMESTAMPTZ,
-- Certificate file
certificate_url TEXT NOT NULL,
certificate_hash TEXT NOT NULL,
-- Lifecycle
status VARCHAR DEFAULT 'valid', -- 'valid', 'expiring_soon', 'expired', 'revoked', 'renewed'
revoked_at TIMESTAMPTZ,
revoked_by UUID REFERENCES profile(id),
revoke_reason TEXT,
-- Renewal chain
is_renewal BOOLEAN DEFAULT false,
previous_certificate_id UUID REFERENCES compliance_certificate(id),
renewed_by_certificate_id UUID REFERENCES compliance_certificate(id),
-- Verification
verification_token VARCHAR UNIQUE NOT NULL, -- for public verification URL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
---
### 3.6 Compliance Dashboard & Reporting
#### Purpose
Give compliance officers and managers real-time visibility into organizational compliance posture.
#### Requirements
**CD-1: Organization Compliance Overview**
- Overall compliance rate (% of required training completed on time)
- Breakdown by: program, department, location, role
- Risk indicators: overdue count, expiring soon count, non-compliant departments
- Trend lines: compliance rate over time
**CD-2: Program-Level Dashboard**
- Assignment status pie chart (completed, in progress, overdue, not started)
- Completion rate over time
- Average time to complete
- Pass/fail rates for assessments
- Upcoming due dates calendar view
**CD-3: Individual Learner View**
- Complete training history
- Current assignments and status
- Certificates earned (with download links)
- Policy attestations
- Compliance "score" or rating
**CD-4: Alerts & Warnings**
- High-risk situations: "50 people overdue on HIPAA training"
- Approaching deadlines: "200 certifications expire next month"
- Completion milestones: "90% of organization completed annual training"
**CD-5: Manager Dashboard**
- My team's compliance rate
- Direct reports' individual status
- Overdue notifications sent
- Team comparison to organization average
**CD-6: Audit Reports**
- One-click generation of compliance packages
- Include: training records, certificates, policy attestations, audit log excerpt
- Tamper-evident (checksums, signed manifest)
- Date range selection
- Filter by: program, department, individual
#### API Endpoints
```typescript
// Organization compliance overview
GET /compliance/dashboard/overview
Response: {
overallComplianceRate: number;
totalAssignments: number;
completedOnTime: number;
completedLate: number;
overdue: number;
inProgress: number;
notStarted: number;
expiringSoon30Days: number;
expiringSoon90Days: number;
byProgram: ProgramComplianceSummary[];
byDepartment: DepartmentComplianceSummary[];
trends: { date: string; rate: number }[];
}
// Program detail dashboard
GET /compliance/dashboard/programs/:programId
Response: {
program: ComplianceProgram;
stats: {
totalAssigned: number;
completed: number;
inProgress: number;
overdue: number;
averageCompletionTime: number;
passRate: number;
};
statusBreakdown: { status: string; count: number; percentage: number }[];
upcomingDueDates: { date: string; count: number }[];
recentCompletions: AssignmentSummary[];
atRiskLearners: AssignmentSummary[]; -- overdue or due soon
}
// Generate audit report
POST /compliance/reports/audit-package
Body: {
title: string;
dateRange: { start: string; end: string };
filters: {
programs?: string[];
departments?: string[];
users?: string[];
};
include: {
trainingRecords: boolean;
certificates: boolean;
policyAttestations: boolean;
auditLog: boolean;
};
}
Response: {
reportId: string;
status: 'generating' | 'ready' | 'failed';
downloadUrl?: string;
expiresAt?: string;
manifest: {
generatedAt: string;
generatedBy: string;
recordCounts: Record<string, number>;
checksums: Record<string, string>;
};
}
// Learner compliance profile
GET /compliance/learners/:userId/profile
Response: {
user: UserSummary;
currentAssignments: AssignmentDetails[];
completedPrograms: CompletedProgram[];
certificates: CertificateSummary[];
policyAttestations: AttestationSummary[];
complianceScore: number;
riskLevel: 'low' | 'medium' | 'high';
}
```
---
### 3.7 Audit Trail & Evidence
#### Purpose
Provide immutable, tamper-evident records of all compliance-related activities for auditor review.
#### Requirements
**AT-1: Comprehensive Event Logging**
Log every compliance-relevant action:
- Program created, modified, archived
- Assignment created, completed, waived, expired
- Certificate issued, revoked, renewed
- Policy published, attested
- Notification sent, opened, clicked
- Report generated, downloaded
- Admin actions (bulk operations, settings changes)
**AT-2: Log Entry Content**
- Timestamp (UTC, millisecond precision)
- Actor (user ID, role, email)
- Action type and description
- Affected resources (program, assignment, user)
- Before/after values for changes
- IP address and user agent
- Session ID
**AT-3: Audit Log Characteristics**
- Append-only (no modifications allowed)
- Retained for 7+ years (configurable)
- Exportable to CSV/JSON/PDF
- Full-text searchable
- Filterable by: date range, actor, action type, resource
**AT-4: Evidence Integrity**
- Cryptographic hash chain linking entries
- Regular integrity verification reports
- Tamper detection alerts
#### Database Schema
```sql
-- Audit event log (append-only)
CREATE TABLE compliance_audit_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Actor
actor_id UUID REFERENCES user(id),
actor_email VARCHAR,
actor_role VARCHAR,
-- Action
action_category VARCHAR NOT NULL, -- 'program', 'assignment', 'certificate', 'policy', 'report', 'admin'
action_type VARCHAR NOT NULL, -- 'created', 'updated', 'completed', 'revoked', etc.
action_description TEXT,
-- Resources
resource_type VARCHAR, -- 'program', 'assignment', 'user', 'certificate'
resource_id UUID,
resource_name VARCHAR,
-- Details
changes JSONB, -- { field: { from: x, to: y } }
metadata JSONB, -- additional context
-- Context
ip_address INET,
user_agent TEXT,
session_id UUID,
-- Integrity
previous_hash VARCHAR,
entry_hash VARCHAR NOT NULL, -- hash of this entry including previous_hash
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_org_time ON compliance_audit_event(organization_id, timestamp DESC);
CREATE INDEX idx_audit_action ON compliance_audit_event(action_category, action_type);
CREATE INDEX idx_audit_resource ON compliance_audit_event(resource_type, resource_id);
CREATE INDEX idx_audit_actor ON compliance_audit_event(actor_id);
```
---
## 4. User Experience Flows
### 4.1 Compliance Officer Journey
```
1. Create Compliance Program
↓
2. Add Content (courses, assessments)
↓
3. Set Assignment Rules (who needs to complete)
↓
4. Configure Schedule & Reminders
↓
5. Launch Program → System auto-assigns to users
↓
6. Monitor Dashboard → See completion rates, overdue alerts
↓
7. Send Reminders / Escalate / Waive as needed
↓
8. Generate Audit Report → Export for auditor
```
### 4.2 Employee (Learner) Journey
```
1. Receive Email: "New Required Training: HIPAA Compliance"
↓
2. Click Link → Login (if needed) → Go to "My Compliance" dashboard
↓
3. See: List of assigned programs with due dates
↓
4. Click Program → Start Training
↓
5. Complete Courses/Assessments
↓
6. Receive Certificate + Confirmation Email
↓
7. Certificate stored in "My Certificates" for future reference
```
### 4.3 Manager Journey
```
1. Weekly Email Digest: "Your Team's Compliance Status"
↓
2. Click to Manager Dashboard
↓
3. See: Team compliance rate, who's overdue, who's due soon
↓
4. Drill into individual → See their assignments
↓
5. Send reminder to specific team member
↓
6. Get notified when team member completes overdue training
```
### 4.4 Auditor Journey
```
1. Compliance officer generates "Audit Package"
↓
2. Secure download link sent to auditor (time-limited)
↓
3. OR: Auditor given time-limited read-only portal access
↓
4. Auditor views/downloads:
- Training completion records
- Certificates with verification
- Policy attestations
- Activity audit log
↓
5. All auditor activity is itself logged
```
---
## 5. Implementation Roadmap
### Phase 1: Core Assignment & Tracking (Weeks 1-6)
**Goal**: Basic compliance program delivery
| Task | Duration | Deliverable |
|------|----------|-------------|
| Compliance program data model | 3 days | Database schema |
| Program CRUD API | 4 days | Backend endpoints |
| Assignment engine | 5 days | Auto-assignment logic |
| Basic compliance dashboard | 5 days | Compliance overview UI |
| "My Compliance" learner view | 4 days | Learner dashboard |
| Assignment tracking | 4 days | Progress tracking UI |
| **Milestone** | | Companies can assign and track compliance training |
### Phase 2: Notifications & Reminders (Weeks 7-10)
**Goal**: Automated follow-up without manual work
| Task | Duration | Deliverable |
|------|----------|-------------|
| Notification template system | 3 days | Template CRUD API/UI |
| Reminder scheduling engine | 4 days | Background job processor |
| Email delivery & tracking | 3 days | Sendgrid/SES integration |
| Manager notifications | 3 days | Manager digest & alerts |
| Escalation workflows | 4 days | Multi-level escalation |
| **Milestone** | | Fully automated reminder system |
### Phase 3: Certificates & Renewals (Weeks 11-14)
**Goal**: Complete certification lifecycle
| Task | Duration | Deliverable |
|------|----------|-------------|
| Certificate templates | 3 days | Template designer |
| Certificate generation | 3 days | PDF generation |
| Certificate lifecycle mgmt | 4 days | Expire, revoke, renew |
| Recurring assignment engine | 4 days | Auto-renewal logic |
| Expiration dashboard | 3 days | Renewals view |
| **Milestone** | | Automated renewal cycles |
### Phase 4: Policy Attestation (Weeks 15-18)
**Goal**: Policy acknowledgment tracking
| Task | Duration | Deliverable |
|------|----------|-------------|
| Policy document management | 3 days | Policy CRUD |
| Document viewer with tracking | 4 days | Time-on-page tracking |
| Attestation workflow | 3 days | Sign/acknowledge flow |
| Attestation dashboard | 3 days | Who's attested view |
| **Milestone** | | Policy attestation system |
### Phase 5: Reporting & Audit (Weeks 19-24)
**Goal**: Audit-ready evidence and reporting
| Task | Duration | Deliverable |
|------|----------|-------------|
| Audit event logging | 4 days | Event capture system |
| Audit log viewer & export | 4 days | Log UI |
| Advanced dashboards | 5 days | Drill-down reports |
| Audit package generator | 5 days | One-click export |
| Auditor portal (read-only) | 5 days | External auditor access |
| **Milestone** | | Full audit readiness |
### Phase 6: Integrations & Scale (Weeks 25-30)
**Goal**: Enterprise integrations
| Task | Duration | Deliverable |
|------|----------|-------------|
| HRIS integration (Workday, BambooHR) | 2 weeks | User sync |
| SCIM provisioning | 1 week | Automated user lifecycle |
| Advanced analytics | 1 week | Trend analysis, predictions |
| API for external systems | 1 week | Public API endpoints |
| **Milestone** | | Enterprise-ready platform |
---
## 6. Technical Architecture
### New API Routes
```
/compliance/programs # CRUD for compliance programs
/compliance/assignments # Assignment management
/compliance/assignments/:id/progress # Track learner progress
/compliance/certificates # Certificate lifecycle
/compliance/policies # Policy document management
/compliance/attestations # Policy acknowledgment
/compliance/dashboard # Dashboard data
/compliance/reports # Report generation
/compliance/audit-events # Audit log access
/compliance/notifications # Notification management
/compliance/settings # Organization compliance settings
```
### Background Jobs
```
assignment-processor # Create assignments based on rules
reminder-scheduler # Queue upcoming reminders
notification-sender # Send emails/SMS
expiration-checker # Mark expired certifications
renewal-processor # Create renewal assignments
report-generator # Generate large audit reports
audit-archiver # Archive old audit logs
```
### Frontend Features
```
apps/dashboard/src/lib/features/compliance/
pages/
programs-list.svelte
program-editor.svelte
assignments.svelte
dashboard.svelte
reports.svelte
audit-log.svelte
settings.svelte
components/
program-card.svelte
assignment-status-badge.svelte
compliance-chart.svelte
notification-template-editor.svelte
certificate-viewer.svelte
policy-uploader.svelte
attestation-flow.svelte
api/
compliance.svelte.ts
utils/
types.ts
constants.ts
```
---
## 7. Success Metrics
### Product Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Time to create compliance program | < 30 minutes | User testing |
| Time to assign training to 1000 people | < 5 minutes | Performance test |
| Training completion rate | > 90% | Dashboard tracking |
| Automated reminder open rate | > 60% | Email analytics |
| Time to generate audit report | < 2 minutes | Performance test |
### Business Metrics
| Metric | Target |
|--------|--------|
| Compliance-focused customer acquisition | 10 new customers in 6 months |
| Compliance feature NPS | > 50 |
| Customer retention (compliance segment) | > 95% |
| Expansion revenue from compliance add-on | 20% of ARR |
---
## 8. Competitive Differentiation
| Feature | ClassroomIO | Competitors (LMS) | Compliance Tools |
|---------|-------------|-------------------|------------------|
| Course creation quality | ✅ Strong | ✅ Strong | ❌ Weak |
| Compliance-specific features | 🆕 Building | ⚠️ Limited | ✅ Strong |
| Price | 💰 Affordable | 💰💰 Expensive | 💰💰💰 Very expensive |
| Open Source | ✅ Yes | ❌ No | ❌ No |
| Self-hosted option | ✅ Yes | ⚠️ Limited | ❌ Rare |
| Ease of use | ✅ Simple | ⚠️ Complex | ❌ Complex |
**Positioning**: "The compliance training platform that doesn't suck - powerful enough for enterprise compliance, simple enough for anyone to use."
---
## 9. Open Questions
1. Should we offer pre-built compliance program templates (e.g., "HIPAA Basics")?
2. Do we need integration with third-party course libraries (Skillsoft, LinkedIn Learning)?
3. Should we support instructor-led training tracking (ILT) or self-paced only?
4. How important is mobile app support for field workers?
5. Do we need e-signature integration (DocuSign) for high-stakes certifications?
6. Should we build a marketplace for compliance content creators?
---
## 10. Next Steps
1. **Validate** this PRD with 3-5 compliance officers at target companies
2. **Prioritize** Phase 1 features for immediate development
3. **Design** the compliance dashboard UI mockups
4. **Prototype** the program creation wizard
5. **Plan** data migration strategy for existing ClassroomIO courses
+814
View File
@@ -0,0 +1,814 @@
# Course Cohorts PRD
## Purpose
Enable instructors to run a single course with multiple independent groups of learners (cohorts). Each cohort has its own enrollment, assignment submissions, grading, newsfeed, attendance, and progress tracking, while sharing the same course content. Cohorts can be deactivated to revoke access or scheduled to start at a future date.
## Problem Statement
Currently, ClassroomIO has a 1:1 relationship between a course and its membership group. If an instructor wants to run the same training for a new batch of students, they must clone the entire course. This leads to:
- Content duplication and drift between copies
- No unified view of all batches running under one course
- No way to deactivate a past batch while keeping content accessible for new ones
- Manual bookkeeping to track which copy belongs to which batch
## Confirmed Decisions
1. **Terminology**: "Cohorts" internally and in the instructor-facing UI.
2. **Opt-in**: Cohorts are opt-in per course. Courses without cohorts work exactly as they do today (default single group).
3. **Cross-cohort analytics**: Instructors can view aggregated analytics across all cohorts.
4. **Student retakes**: A student can be enrolled in multiple cohorts of the same course (e.g., retake scenarios).
5. **Deadlines**: Cohort-level deadlines are controlled by `cohort.startsAt` / `cohort.endsAt` and `cohort.status`. Exercise-level `dueBy` on the shared content is unrelated to cohort scheduling.
6. **Student visibility**: Students never see or choose cohorts. Cohorts are an instructor-only organizational concept. Students are enrolled into a specific cohort via invite links created by the instructor. The student experience is identical to today — they just see "the course."
7. **URLs**: The course landing page URL does not change. Cohort context is carried by the invite token, not by the URL.
## Current-State Audit
| Capability | Current State | Notes |
| --- | --- | --- |
| Course ↔ Group relationship | 1:1 via `course.groupId` | Single group per course |
| Student enrollment | `groupmember` rows in course's group | Tied to one group |
| Submissions | `submission.submittedBy` → `groupmember.id` | Already group-scoped |
| Question answers | `question_answer.groupMemberId` → `groupmember.id` | Already group-scoped |
| Attendance | `group_attendance.studentId` → `groupmember.id` | Already group-scoped |
| Lesson completion | `lesson_completion(lessonId, profileId)` unique | NOT group-scoped, uses `profileId` |
| Lesson comments | `lesson_comment.groupmemberId` → `groupmember.id` | Already group-scoped |
| Course newsfeed | `course_newsfeed.courseId` (no group filter) | Shared across all members, NOT group-scoped |
| Newsfeed comments | FK to `course_newsfeed` | Follows parent newsfeed |
| Course content | Lessons, exercises, sections belong to `courseId` | Shared (this is what we want) |
| Course creation | Creates group → course → groupmember → default newsfeed in transaction | `apps/api/src/services/course/course.ts` |
| Enrollment | `enrollInCourse()` adds to `course.groupId` group | `apps/api/src/services/course/invite.ts` |
| Course type enum | `COURSE_TYPE`: `SELF_PACED`, `LIVE_CLASS` | `packages/db/src/schema.ts:23` |
**Key insight**: Submissions, question answers, attendance, and lesson comments are already scoped by `groupmember.id`. If each cohort gets its own `group`, these records are naturally isolated with zero schema changes to those tables.
## Product Goals
1. Allow a course to have multiple cohorts, each with its own independent group of learners.
2. Each cohort has isolated: enrollment, submissions, grading, newsfeed, attendance, progress tracking.
3. All cohorts share the same course content (lessons, exercises, sections, questions).
4. Instructors can deactivate a cohort (students lose access) or schedule access windows.
5. Courses without cohorts enabled continue to work exactly as they do today.
6. Instructors can view per-cohort and cross-cohort aggregated analytics.
7. Students can be enrolled in multiple cohorts of the same course.
## Non-Goals (v1)
- Per-cohort exercise deadline overrides (exercise `dueBy` differences between cohorts).
- Cohort-specific content variations (all cohorts see the same content).
- Automatic cohort assignment rules (e.g., auto-assign by domain).
- Cohort templates or cohort cloning.
- Cohort-level pricing (all cohorts share the course price).
- Notifications/emails when a cohort is about to start or end.
## Data Sources Checked
- `packages/db/src/schema.ts` — all table definitions
- `apps/api/src/services/course/course.ts` — course creation flow
- `apps/api/src/services/course/invite.ts` — enrollment flow
- `apps/api/src/routes/course/course.ts` — course routes
- `apps/api/src/routes/course/` — all course sub-routers
- `packages/db/src/queries/course/` — course query layer
- `packages/db/src/queries/course/people.ts` — member management
- `packages/db/src/queries/newsfeed/newsfeed.ts` — newsfeed queries
- `packages/db/src/queries/submission/submission.ts` — submission queries
- `packages/db/src/queries/mark/mark.ts` — grading queries
- `apps/dashboard/src/routes/courses/[id]/` — course detail pages
- `apps/dashboard/src/lib/features/course/` — course frontend feature
## Functional Requirements
### 1. Enable Cohorts on a Course
- Instructors can enable cohorts on an existing course via course settings.
- When cohorts are enabled:
- A `course_cohort` record is created for each cohort.
- Each cohort gets its own `group` for membership isolation.
- The existing `course.groupId` group becomes the "Default" cohort (backward compatible).
- When cohorts are disabled (reverted):
- Only allowed if there is exactly one active cohort.
- That cohort's group becomes the direct `course.groupId` again.
### 2. Cohort CRUD
- Instructors can create, update, and archive cohorts.
- Each cohort has: name, description, status, optional start/end dates, optional max student cap.
- Cohort names must be unique within a course.
- Creating a cohort creates a new `group` and adds the creator as TUTOR in that group.
- A default welcome newsfeed post is created for each new cohort.
### 3. Cohort Status and Access Control
- Cohort statuses: `ACTIVE`, `INACTIVE`, `SCHEDULED`.
- Access rules enforced server-side (not just UI):
- `ACTIVE`: full access for enrolled students.
- `INACTIVE`: students cannot access course content. Instructors/admins can still view for grading/review.
- `SCHEDULED` with `startsAt` in the future: students see "This cohort starts on [date]" message.
- `endsAt` in the past: treated as `INACTIVE` regardless of status field.
- Deactivating a cohort does NOT remove group members or delete data. It only blocks access.
### 4. Cohort-Scoped Enrollment
- Students are enrolled into a specific cohort, not the course directly.
- Enrollment creates a `groupmember` in the cohort's `group`.
- A student can exist in multiple cohorts of the same course (retakes).
- Invite links are scoped to a cohort (not the course). The instructor creates an invite for a specific cohort, and the generated link carries that context via the invite token.
- The existing `course_invite` table gets a nullable `cohort_id` column.
- **Students never see or select cohorts.** The cohort is resolved server-side from the invite token. From the student's perspective, they click an invite link and join "the course" — the cohort assignment is invisible to them.
- **Free course direct enrollment** (no invite token): when cohorts are enabled, the instructor designates one cohort as the "open enrollment" cohort. The course-level `POST /course/:courseId/enroll` (no token) routes to that cohort's group. This is stored as `course.defaultCohortId`. If no default cohort is set, direct free enrollment returns an error asking the student to use an invite link.
### 4a. URL and Landing Page Behavior
- **Landing page** (`/course/[slug]`): Unchanged. Shows the shared course info. There is one course, one slug, one landing page regardless of how many cohorts exist.
- **Enroll page** (`/course/[slug]/enroll?invite_token=...`): Unchanged URL structure. The invite token carries the `cohortId` — the backend resolves it and enrolls the student into the correct cohort's group.
- **Free enrollment** (`/course/[slug]/enroll` with no token): Routes to the course's default cohort when cohorts are enabled.
- **Inside the LMS** (`/courses/[id]/...`): Students see the course as they do today. The backend resolves which cohort the student belongs to and returns cohort-scoped data (newsfeed, submissions, progress). If a student is in multiple cohorts (retake), the backend uses the most recently active cohort by default, with the option for the URL to carry `?cohort=[cohortId]` — but this is not surfaced in the UI as a "cohort picker." Instead, the course shows a simple "You have a previous enrollment" note with the option to switch, without exposing cohort terminology.
### 5. Cohort-Scoped Newsfeed
- Each cohort has its own newsfeed.
- Add `cohort_id` (nullable) to `course_newsfeed`.
- When querying newsfeed, filter by `cohortId`.
- For non-cohort courses, `cohort_id` is null and behavior is unchanged.
### 6. Cohort-Scoped Lesson Completion
- Add `cohort_id` (nullable) to `lesson_completion`.
- Change unique constraint from `(lesson_id, profile_id)` to `(lesson_id, profile_id, cohort_id)`.
- A student's lesson completion in one cohort does not affect another.
- For non-cohort courses, `cohort_id` is null and behavior is unchanged.
### 7. Per-Cohort Views (Dashboard)
- **Instructor view**: When viewing a cohort-enabled course, the instructor sees a cohort selector dropdown. Per-cohort pages: newsfeed, people, submissions, marks, attendance, analytics. All data is filtered by the selected cohort.
- **Student view**: Students never see a cohort selector or cohort names. The backend automatically resolves the student's cohort from their `groupmember` record and returns cohort-scoped data. If enrolled in multiple cohorts (retake), the most recently active cohort is used by default.
### 8. Cross-Cohort Analytics
- Instructors can view aggregated analytics across all cohorts of a course.
- Metrics: total students, lesson completion rates, exercise completion rates, average grades — broken down by cohort and as totals.
### 9. Migration for Existing Courses
- When a course has cohorts enabled, a "Default" cohort is auto-created pointing to the existing `course.groupId` group.
- All existing newsfeed posts for that course get the default cohort's `cohort_id` backfilled.
- All existing `lesson_completion` rows for members of that group get the default cohort's `cohort_id` backfilled.
- This ensures zero data loss and full backward compatibility.
## Technical Design
### Data Model
#### New Table: `course_cohort`
```typescript
// packages/db/src/schema.ts
export const cohortStatus = pgEnum('COHORT_STATUS', ['ACTIVE', 'INACTIVE', 'SCHEDULED']);
export const courseCohort = pgTable(
'course_cohort',
{
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
courseId: uuid('course_id').notNull(),
groupId: uuid('group_id').notNull(),
name: varchar().notNull(),
description: text(),
status: cohortStatus().default('ACTIVE').notNull(),
startsAt: timestamp('starts_at', { withTimezone: true, mode: 'string' }),
endsAt: timestamp('ends_at', { withTimezone: true, mode: 'string' }),
maxStudents: integer('max_students'),
createdByProfileId: uuid('created_by_profile_id'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_cohort_course_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.groupId],
foreignColumns: [group.id],
name: 'course_cohort_group_id_fkey'
}),
foreignKey({
columns: [table.createdByProfileId],
foreignColumns: [profile.id],
name: 'course_cohort_created_by_profile_id_fkey'
}),
unique('course_cohort_course_name_unique').on(table.courseId, table.name),
index('idx_course_cohort_course_id').on(table.courseId),
index('idx_course_cohort_group_id').on(table.groupId)
]
);
```
#### Modified Table: `course`
Add cohort mode flag and default cohort reference:
```typescript
// Add to course table definition
cohortsEnabled: boolean('cohorts_enabled').default(false).notNull(),
defaultCohortId: uuid('default_cohort_id')
// Add to constraints
foreignKey({
columns: [table.defaultCohortId],
foreignColumns: [courseCohort.id],
name: 'course_default_cohort_id_fkey'
})
```
- `cohortsEnabled` — whether cohort mode is active for this course.
- `defaultCohortId` — the cohort used for free direct enrollment (no invite token). When cohorts are first enabled, this points to the auto-created "Default" cohort. Instructors can change it to any active cohort.
#### Modified Table: `course_newsfeed`
Add nullable cohort reference:
```typescript
// Add to course_newsfeed table definition
cohortId: uuid('cohort_id')
// Add to constraints
foreignKey({
columns: [table.cohortId],
foreignColumns: [courseCohort.id],
name: 'course_newsfeed_cohort_id_fkey'
})
```
#### Modified Table: `lesson_completion`
Add nullable cohort reference and update unique constraint:
```typescript
// Add to lesson_completion table definition
cohortId: uuid('cohort_id')
// Add to constraints
foreignKey({
columns: [table.cohortId],
foreignColumns: [courseCohort.id],
name: 'lesson_completion_cohort_id_fkey'
})
// Replace existing unique constraint:
// OLD: unique('unique_lesson_profile').on(table.lessonId, table.profileId)
// NEW: unique('unique_lesson_profile_cohort').on(table.lessonId, table.profileId, table.cohortId)
```
#### Modified Table: `course_invite`
Add nullable cohort reference:
```typescript
// Add to course_invite table definition
cohortId: uuid('cohort_id')
// Add to constraints
foreignKey({
columns: [table.cohortId],
foreignColumns: [courseCohort.id],
name: 'course_invite_cohort_id_fkey'
})
```
#### Relationship Diagram
```
course (content: lessons, exercises, sections, questions)
├── cohortsEnabled: boolean
├── defaultCohortId → course_cohort (for free enrollment without invite token)
│
├── [cohortsEnabled = false] → course.groupId → group → groupmember (legacy 1:1 behavior)
│
└── [cohortsEnabled = true] → course_cohort (one per batch)
├── name, status, startsAt, endsAt
└── groupId → group → groupmember (enrollment per cohort)
├── submission (already scoped by groupmember.id)
├── question_answer (already scoped by groupmember.id)
├── group_attendance (already scoped by groupmember.id)
└── lesson_comment (already scoped by groupmember.id)
course_newsfeed.cohortId → scoped per cohort
lesson_completion.cohortId → scoped per cohort
URL structure (unchanged for students):
/course/[slug] → landing page (shared, cohort-unaware)
/course/[slug]/enroll?invite_token=abc123 → enroll (cohort resolved from token)
/course/[slug]/enroll → enroll (free, routes to defaultCohortId)
/courses/[id]/... → LMS (cohort resolved from groupmember)
```
### Migration
Create migration file: `packages/db/src/migrations/XXXX_add_course_cohorts.sql`
```sql
-- 1. Create cohort status enum
CREATE TYPE "COHORT_STATUS" AS ENUM ('ACTIVE', 'INACTIVE', 'SCHEDULED');
-- 2. Create course_cohort table
CREATE TABLE course_cohort (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES "group"(id),
name VARCHAR(255) NOT NULL,
description TEXT,
status "COHORT_STATUS" NOT NULL DEFAULT 'ACTIVE',
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
max_students INTEGER,
created_by_profile_id UUID REFERENCES profile(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(course_id, name)
);
CREATE INDEX idx_course_cohort_course_id ON course_cohort(course_id);
CREATE INDEX idx_course_cohort_group_id ON course_cohort(group_id);
-- 3. Add cohorts_enabled and default_cohort_id to course
ALTER TABLE course ADD COLUMN cohorts_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE course ADD COLUMN default_cohort_id UUID REFERENCES course_cohort(id);
-- 4. Add cohort_id to course_newsfeed
ALTER TABLE course_newsfeed ADD COLUMN cohort_id UUID REFERENCES course_cohort(id);
-- 5. Add cohort_id to lesson_completion
ALTER TABLE lesson_completion ADD COLUMN cohort_id UUID REFERENCES course_cohort(id);
-- 6. Drop old unique constraint and create new one on lesson_completion
ALTER TABLE lesson_completion DROP CONSTRAINT IF EXISTS unique_lesson_profile;
ALTER TABLE lesson_completion ADD CONSTRAINT unique_lesson_profile_cohort
UNIQUE (lesson_id, profile_id, cohort_id);
-- 7. Add cohort_id to course_invite
ALTER TABLE course_invite ADD COLUMN cohort_id UUID REFERENCES course_cohort(id);
```
### Access Control Logic
```typescript
// apps/api/src/services/course/cohort.ts
function canAccessCohort(cohort: { status: string; startsAt: string | null; endsAt: string | null }): boolean {
if (cohort.status === 'INACTIVE') return false;
const now = new Date();
if (cohort.status === 'SCHEDULED' && cohort.startsAt && now < new Date(cohort.startsAt)) return false;
if (cohort.endsAt && now > new Date(cohort.endsAt)) return false;
return true;
}
```
This check must be enforced:
- In route middleware for any cohort-scoped endpoint.
- Before serving course content to students in a specific cohort.
- Exception: instructors/admins always have access for grading and review.
## API and Route Plan
### Validation Layer
File: `packages/utils/src/validation/course/cohort.ts`
```typescript
import { z } from 'zod';
export const ZCreateCohort = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
status: z.enum(['ACTIVE', 'INACTIVE', 'SCHEDULED']).default('ACTIVE'),
startsAt: z.string().datetime().optional(),
endsAt: z.string().datetime().optional(),
maxStudents: z.number().int().positive().optional()
});
export type TCreateCohort = z.infer<typeof ZCreateCohort>;
export const ZUpdateCohort = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional(),
status: z.enum(['ACTIVE', 'INACTIVE', 'SCHEDULED']).optional(),
startsAt: z.string().datetime().nullable().optional(),
endsAt: z.string().datetime().nullable().optional(),
maxStudents: z.number().int().positive().nullable().optional()
});
export type TUpdateCohort = z.infer<typeof ZUpdateCohort>;
export const ZEnableCohorts = z.object({
enabled: z.boolean()
});
export type TEnableCohorts = z.infer<typeof ZEnableCohorts>;
```
Export from: `packages/utils/src/validation/course/index.ts`
### Query Layer
File: `packages/db/src/queries/course/cohort.ts`
Functions:
- `createCohort(data)` — insert into `course_cohort` with given `groupId`.
- `getCohortById(cohortId)` — single cohort with group member count.
- `getCohortsByCourseId(courseId)` — all cohorts for a course, ordered by `createdAt`, with member counts.
- `updateCohort(cohortId, data)` — update name, description, status, dates, maxStudents.
- `getCohortByGroupId(groupId)` — look up cohort from a group ID (useful for resolving context).
- `getActiveCohortForMember(courseId, profileId)` — find which cohort(s) a student belongs to in a course.
Export from: `packages/db/src/queries/course/index.ts`
### Service Layer
File: `apps/api/src/services/course/cohort.ts`
Functions:
#### `enableCohorts(courseId, profileId)`
- Verify course exists and user is admin/tutor.
- Create "Default" cohort pointing to existing `course.groupId`.
- Set `course.cohortsEnabled = true` and `course.defaultCohortId` to the new default cohort.
- Backfill `cohort_id` on existing `course_newsfeed` rows for this course.
- Backfill `cohort_id` on existing `lesson_completion` rows for members of this course's group.
- All in a transaction.
#### `disableCohorts(courseId)`
- Verify there is exactly one active cohort.
- Set `course.cohortsEnabled = false`.
- Nullify `cohort_id` on newsfeed and lesson_completion rows.
- All in a transaction.
#### `createCohort(courseId, profileId, data: TCreateCohort)`
- Verify course has cohorts enabled.
- Create new `group` (name = cohort name, organizationId from course's org).
- Insert `course_cohort` row.
- Add creator as TUTOR `groupmember` in the new group.
- Create default welcome newsfeed post for the cohort.
- All in a transaction.
- Return the new cohort.
#### `updateCohort(cohortId, data: TUpdateCohort)`
- Validate dates (if `endsAt` provided, must be after `startsAt`).
- Update the `course_cohort` row.
- Return the updated cohort.
#### `deactivateCohort(cohortId)`
- Set `status = 'INACTIVE'`.
- Return the updated cohort.
#### `enrollInCohort(cohortId, user, context)`
- Verify cohort exists and `canAccessCohort()`.
- Check `maxStudents` cap if set.
- Add student to cohort's group via `groupmember`.
- Handle org membership creation if needed (same pattern as `enrollInCourse`).
- Idempotent: if student is already in this cohort's group, return success.
#### `getCohortAnalytics(cohortId)`
- Same logic as `getCourseAnalytics` but filtered to the cohort's group members.
#### `getCrossCohortsAnalytics(courseId)`
- Aggregate analytics across all cohorts of a course.
- Return per-cohort breakdown plus totals.
### Route Layer
File: `apps/api/src/routes/course/cohort.ts`
All routes are nested under `/course/:courseId/cohort` and require `authMiddleware`.
| Method | Path | Description | Auth |
| --- | --- | --- | --- |
| `POST` | `/course/:courseId/cohort/enable` | Enable/disable cohorts on course | Admin/Tutor |
| `GET` | `/course/:courseId/cohort` | List all cohorts for a course | Admin/Tutor |
| `POST` | `/course/:courseId/cohort` | Create a new cohort | Admin/Tutor |
| `GET` | `/course/:courseId/cohort/:cohortId` | Get cohort details | Admin/Tutor |
| `PUT` | `/course/:courseId/cohort/:cohortId` | Update cohort | Admin/Tutor |
| `POST` | `/course/:courseId/cohort/:cohortId/deactivate` | Deactivate cohort | Admin/Tutor |
| `POST` | `/course/:courseId/cohort/:cohortId/enroll` | Enroll student in cohort | Authenticated |
| `GET` | `/course/:courseId/cohort/:cohortId/analytics` | Cohort analytics | Admin/Tutor |
| `GET` | `/course/:courseId/cohort/analytics` | Cross-cohort analytics | Admin/Tutor |
#### Route Registration
```typescript
// apps/api/src/routes/course/cohort.ts
export const cohortRouter = new Hono()
.post('/enable', authMiddleware, zValidator('json', ZEnableCohorts), async (c) => { ... })
.get('/', authMiddleware, async (c) => { ... })
.post('/', authMiddleware, zValidator('json', ZCreateCohort), async (c) => { ... })
// ... etc
// apps/api/src/routes/course/index.ts
export * from './cohort';
// In the course router setup (apps/api/src/routes/course/course.ts or where sub-routers are mounted):
.route('/:courseId/cohort', cohortRouter)
```
### Modified Existing Endpoints
Several existing endpoints need cohort-awareness:
#### Newsfeed Routes (`apps/api/src/routes/course/newsfeed.ts`)
- `GET /course/:courseId/newsfeed` — accept optional `cohortId` query param. If course has cohorts enabled, `cohortId` is required and newsfeed is filtered by it.
- `POST /course/:courseId/newsfeed` — accept optional `cohortId` in body. If course has cohorts enabled, `cohortId` is required.
#### Submission Routes (`apps/api/src/routes/course/submission.ts`)
- Already scoped by `groupmember.id`, but list endpoints that join through `course.groupId` need to accept a `cohortId` param to filter to the correct group.
- `GET /course/:courseId/submission/for-grading` — add `cohortId` query param.
#### People Routes (`apps/api/src/routes/course/people.ts`)
- `GET /course/:courseId/people` — add `cohortId` query param to list members of a specific cohort's group.
#### Marks Routes (`apps/api/src/routes/course/mark.ts`)
- `GET /course/:courseId/marks` — add `cohortId` query param to filter marks by cohort group.
#### Attendance Routes (`apps/api/src/routes/course/attendance.ts`)
- Already scoped by `groupmember.id`. List endpoints need `cohortId` param.
#### Course Progress (`apps/api/src/services/course/course.ts`)
- `getCourseProgress` — pass `cohortId` to filter `lesson_completion` by cohort.
#### Enrollment (`apps/api/src/services/course/invite.ts`)
- `enrollInCourse` (no invite token, free courses) — if course has cohorts enabled:
- Use `course.defaultCohortId` as the target cohort.
- If `defaultCohortId` is null, return error: "This course requires an invite link to enroll."
- Verify cohort is active via `canAccessCohort()`.
- Enroll student into the default cohort's group, not `course.groupId`.
- `course_invite` creation — attach `cohortId` when course has cohorts enabled. Instructor selects the target cohort when creating an invite.
- `acceptStudentInvite` — read `cohort_id` from invite to determine target group. Enroll into the cohort's group instead of `course.groupId`.
### Build Verification
After implementing backend changes:
```bash
pnpm --filter @cio/utils build
pnpm --filter @cio/db build
pnpm --filter @cio/api build
```
## Frontend Plan (Dashboard)
### Request Types
File: `apps/dashboard/src/lib/features/course/utils/types.ts`
Add:
```typescript
export type ListCohortsRequest = typeof classroomio.course[':courseId']['cohort']['$get'];
export type CreateCohortRequest = typeof classroomio.course[':courseId']['cohort']['$post'];
export type UpdateCohortRequest = typeof classroomio.course[':courseId']['cohort'][':cohortId']['$put'];
export type EnableCohortsRequest = typeof classroomio.course[':courseId']['cohort']['enable']['$post'];
export type DeactivateCohortRequest = typeof classroomio.course[':courseId']['cohort'][':cohortId']['deactivate']['$post'];
export type CohortAnalyticsRequest = typeof classroomio.course[':courseId']['cohort'][':cohortId']['analytics']['$get'];
export type ListCohortsSuccess = Extract<InferResponseType<ListCohortsRequest>, { success: true }>;
export type Cohort = ListCohortsSuccess['data'][number];
```
### API Class
File: `apps/dashboard/src/lib/features/course/api/cohort.svelte.ts`
```typescript
class CohortApi extends BaseApiWithErrors {
cohorts = $state<Cohort[]>([]);
selectedCohortId = $state<string | null>(null);
loading = $state(false);
get selectedCohort() {
return this.cohorts.find(c => c.id === this.selectedCohortId) ?? null;
}
async list(courseId: string) { ... }
async create(courseId: string, data: TCreateCohort) { ... }
async update(courseId: string, cohortId: string, data: TUpdateCohort) { ... }
async enable(courseId: string, enabled: boolean) { ... }
async deactivate(courseId: string, cohortId: string) { ... }
}
```
### Cohort Selector Component
File: `apps/dashboard/src/lib/features/course/components/cohort-selector.svelte`
- Dropdown in the course header/sidebar.
- Shows cohort name and status badge (active/inactive/scheduled).
- Instructors can switch between cohorts.
- Includes "Manage Cohorts" link to cohort management page.
### Cohort Management Page
Route: `apps/dashboard/src/routes/courses/[id]/cohorts/+page.svelte`
Feature page: `apps/dashboard/src/lib/features/course/pages/cohorts.svelte`
Features:
- List all cohorts with name, status, student count, dates.
- Create new cohort (name, description, status, dates, max students).
- Edit cohort settings.
- Deactivate/reactivate cohort.
- Status badges: Active (green), Inactive (gray), Scheduled (blue).
### Enable Cohorts Toggle
In course settings page (`apps/dashboard/src/routes/courses/[id]/settings/`):
- Add "Enable Cohorts" toggle.
- When toggled on: creates default cohort, shows success message.
- When toggled off: only works if single cohort, shows confirmation dialog.
### Student Multi-Enrollment Handling
Students are never exposed to cohort terminology. For the rare case where a student is enrolled in multiple cohorts of the same course (retake):
- The backend defaults to the most recently active cohort.
- The course page shows a subtle note: "You have a previous enrollment. [Switch]" — without using the word "cohort."
- Switching passes `?cohort=[cohortId]` as a query param internally, but the UI labels it generically (e.g., "January 2026 Training" vs "June 2026 Training" — using the cohort name set by the instructor).
- If enrolled in only one cohort (the common case), no extra UI is shown.
### Modified Existing Pages
These existing pages need to pass `cohortId` when the course has cohorts enabled:
| Page | File | Change |
| --- | --- | --- |
| Newsfeed | `apps/dashboard/src/lib/features/course/pages/newsfeed.svelte` | Pass `cohortId` to newsfeed API calls |
| People | Course people page | Pass `cohortId` to filter group members |
| Submissions | `apps/dashboard/src/lib/features/course/pages/submissions.svelte` | Pass `cohortId` to list submissions |
| Marks | `apps/dashboard/src/lib/features/course/pages/marks.svelte` | Pass `cohortId` to filter marks |
| Attendance | `apps/dashboard/src/lib/features/course/pages/attendance.svelte` | Pass `cohortId` to filter attendance |
| Analytics | `apps/dashboard/src/lib/features/course/pages/analytics.svelte` | Pass `cohortId` or show cross-cohort toggle |
| Course Progress | Student progress tracking | Pass `cohortId` to filter `lesson_completion` |
### Navigation Update
File: `apps/dashboard/src/lib/features/course/` (course sidebar/nav)
- Add "Cohorts" nav item (visible only when cohorts enabled, for instructors).
- Cohort selector visible in sidebar header when cohorts enabled.
### Translations
File: `apps/dashboard/src/lib/utils/translations/en.json`
Add keys under `course.cohorts`:
```json
{
"course": {
"cohorts": {
"title": "Cohorts",
"create": "Create Cohort",
"enable": "Enable Cohorts",
"disable": "Disable Cohorts",
"name": "Cohort Name",
"description": "Description",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"scheduled": "Scheduled",
"starts_at": "Starts At",
"ends_at": "Ends At",
"max_students": "Max Students",
"student_count": "Students",
"deactivate": "Deactivate Cohort",
"reactivate": "Reactivate Cohort",
"selector_label": "Select Cohort",
"manage": "Manage Cohorts",
"default_name": "Default",
"default_welcome": "Welcome to this cohort!",
"enable_confirm": "This will organize your current students into a default cohort. You can then create additional cohorts.",
"disable_confirm": "You can only disable cohorts if there is exactly one active cohort.",
"deactivate_confirm": "Students in this cohort will lose access to the course. Their data will be preserved.",
"access_blocked": "This cohort is not currently active.",
"scheduled_message": "This cohort starts on {date}.",
"ended_message": "This cohort has ended.",
"no_cohorts": "No cohorts yet. Create one to get started.",
"cross_cohort_analytics": "All Cohorts",
"previous_enrollment": "You have a previous enrollment.",
"switch_enrollment": "Switch",
"requires_invite": "This course requires an invite link to enroll."
}
}
}
```
### Frontend Build Verification
```bash
pnpm --filter @cio/dashboard build
```
## Implementation Order
### Phase 1: Database + Schema (do first)
1. Add `cohortStatus` enum to schema.
2. Add `courseCohort` table to schema.
3. Add `cohortsEnabled` and `defaultCohortId` columns to `course` table.
4. Add `cohortId` column to `course_newsfeed`.
5. Add `cohortId` column to `lesson_completion` and update unique constraint.
6. Add `cohortId` column to `course_invite`.
7. Generate and apply migration.
8. Run `pnpm --filter @cio/db build`.
### Phase 2: Validation + Queries (do second)
1. Create `packages/utils/src/validation/course/cohort.ts` with all Zod schemas.
2. Export from `packages/utils/src/validation/course/index.ts`.
3. Create `packages/db/src/queries/course/cohort.ts` with all query functions.
4. Export from `packages/db/src/queries/course/index.ts`.
5. Run `pnpm --filter @cio/utils build` and `pnpm --filter @cio/db build`.
### Phase 3: Service + Routes (do third)
1. Create `apps/api/src/services/course/cohort.ts` with all service functions.
2. Create `apps/api/src/routes/course/cohort.ts` with all route handlers.
3. Register cohort router in course route index.
4. Add new error codes to `apps/api/src/utils/errors.ts` if needed (e.g., `COHORT_NOT_FOUND`, `COHORTS_NOT_ENABLED`, `COHORT_ACCESS_DENIED`, `COHORT_FULL`).
5. Update existing newsfeed, submission, people, marks, attendance routes to accept `cohortId` param.
6. Update `enrollInCourse` to handle cohort-aware enrollment.
7. Run `pnpm --filter @cio/api build`.
### Phase 4: Frontend — Cohort Management (do fourth)
1. Add request types to `apps/dashboard/src/lib/features/course/utils/types.ts`.
2. Create `apps/dashboard/src/lib/features/course/api/cohort.svelte.ts` API class.
3. Add translation keys to `en.json`.
4. Create cohort management page: `apps/dashboard/src/routes/courses/[id]/cohorts/+page.svelte` and feature page.
5. Add "Enable Cohorts" toggle to course settings.
6. Add cohort selector component.
7. Add "Cohorts" to course navigation.
8. Run `pnpm --filter @cio/dashboard build`.
### Phase 5: Frontend — Cohort-Scoped Views (do fifth)
1. Update newsfeed page to pass `cohortId`.
2. Update people page to pass `cohortId`.
3. Update submissions page to pass `cohortId`.
4. Update marks page to pass `cohortId`.
5. Update attendance page to pass `cohortId`.
6. Update analytics page with per-cohort / cross-cohort toggle.
7. Update course progress to pass `cohortId`.
8. Add backend cohort resolution for student views (auto-resolve from `groupmember`, default to most recent active cohort).
9. Add subtle "previous enrollment" switcher for multi-cohort students (no cohort terminology exposed).
10. Run `pnpm --filter @cio/dashboard build`.
## Acceptance Criteria
1. Instructor can enable cohorts on an existing course and a "Default" cohort is auto-created with all existing members.
2. Instructor can create a new cohort with name, description, status, dates.
3. Students enrolled in Cohort A cannot see submissions, newsfeed, or attendance of Cohort B.
4. Deactivating a cohort blocks student access but preserves all data.
5. A student can be enrolled in multiple cohorts of the same course.
6. Courses without cohorts enabled work exactly as they do today (zero regression).
7. Lesson completion is tracked independently per cohort.
8. Newsfeed posts are scoped to the cohort they were created in.
9. Instructor can view aggregated analytics across all cohorts.
10. Invite links are scoped to a specific cohort when cohorts are enabled.
11. Cohort status `SCHEDULED` with future `startsAt` blocks student access until the start date.
12. Students never see the word "cohort" or any cohort selection UI. Cohort assignment is fully transparent to students.
13. Course landing page URL (`/course/[slug]`) is unchanged regardless of cohort state.
14. Free course enrollment routes to the course's default cohort when cohorts are enabled.
15. All new UI strings use translation keys (no hardcoded English).
16. `pnpm --filter @cio/api build` and `pnpm --filter @cio/dashboard build` pass after implementation.
## Risks and Mitigations
- **Risk**: Existing queries that join through `course.groupId` break when cohorts are enabled (course has multiple groups now).
- **Mitigation**: When cohorts are enabled, all member-facing queries must go through `course_cohort.groupId`, not `course.groupId`. The `course.groupId` field remains but only points to the default/first cohort's group. Queries should check `cohortsEnabled` and branch accordingly.
- **Risk**: Lesson completion unique constraint migration fails on existing duplicate data.
- **Mitigation**: The new constraint includes `cohort_id` which is null for all existing rows. Since `(lesson_id, profile_id, NULL)` is still unique under the old data, the migration is safe. PostgreSQL treats each NULL as distinct in unique constraints, so we need a partial unique index instead: `UNIQUE (lesson_id, profile_id) WHERE cohort_id IS NULL` plus `UNIQUE (lesson_id, profile_id, cohort_id) WHERE cohort_id IS NOT NULL`.
- **Risk**: Performance degradation with many cohorts per course.
- **Mitigation**: Index `course_cohort.course_id`. Member counts are computed via joins, not stored. For cross-cohort analytics, batch queries per cohort and aggregate in service layer.
- **Risk**: Student enrolled in multiple cohorts (retake) doesn't know which enrollment they're viewing.
- **Mitigation**: Backend defaults to most recently active cohort. For the rare multi-enrollment case, a subtle "You have a previous enrollment" note is shown without cohort terminology. Students never see a cohort picker or cohort names directly.
## Updated Unique Constraint Strategy for `lesson_completion`
Due to PostgreSQL treating NULL as distinct in unique constraints, use two constraints:
```sql
-- For non-cohort courses (cohort_id IS NULL)
CREATE UNIQUE INDEX unique_lesson_profile_no_cohort
ON lesson_completion (lesson_id, profile_id)
WHERE cohort_id IS NULL;
-- For cohort courses (cohort_id IS NOT NULL)
CREATE UNIQUE INDEX unique_lesson_profile_cohort
ON lesson_completion (lesson_id, profile_id, cohort_id)
WHERE cohort_id IS NOT NULL;
```
This replaces the simple `UNIQUE(lesson_id, profile_id, cohort_id)` approach and ensures correct behavior for both cohort and non-cohort courses.
+239
View File
@@ -0,0 +1,239 @@
# Course Importer Implementation Plan (Creator Business v1)
## Purpose
Ship a long-running course importer that supports creator-focused sources:
1. Teachable API
2. Thinkific API
3. SCORM ZIP
4. CSV (platform-agnostic template)
## Scope Lock (v1)
1. Import is organization-scoped and admin-only.
2. Import runs as an async DB-backed job (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELED`).
3. Content imported:
- Course metadata (title, description, cover where available)
- Sections/modules
- Lessons/lectures/pages
- Exercises/quizzes/questions/options (when available)
- Media as external links or attached assets (no bulk media re-hosting in v1)
4. Not in v1:
- Enrollment/user migration (except CSV enrollment template if enabled)
- Billing/payment migration
- Full SCORM runtime tracking parity
## Delivery Order
1. Shared job infrastructure
2. Teachable provider
3. Thinkific provider
4. CSV provider
5. SCORM provider
## Shared Foundation (All Providers)
### A. Data Model + Queries
1. Add `course_import_job` table:
- `id`, `organizationId`, `createdByProfileId`
- `provider`, `status`, `attempt`, `maxAttempts`
- `leasedBy`, `leaseExpiresAt`, `heartbeatAt`
- `progressPercent`, `stage`, `checkpoint` (jsonb)
- `sourceCourseId`, `sourceCourseTitle`, `targetCourseId`
- `summary` (jsonb), `warnings` (jsonb), `error` (jsonb), timestamps
2. Add `course_import_log` table:
- `jobId`, `level`, `code`, `message`, `meta` (jsonb), timestamp
3. Add query module:
- `packages/db/src/queries/course-import/course-import.ts`
- Export from `packages/db/src/queries/index.ts`
### B. Validation + API Contracts
1. Add validation module:
- `packages/utils/src/validation/course-import/course-import.ts`
2. Endpoints:
- `GET /organization/course-import/providers`
- `POST /organization/course-import/preview`
- `POST /organization/course-import/jobs`
- `GET /organization/course-import/jobs`
- `GET /organization/course-import/jobs/:jobId`
- `POST /organization/course-import/jobs/:jobId/retry`
- `POST /organization/course-import/jobs/:jobId/cancel`
### C. Worker
1. Add worker entrypoint:
- `apps/api/src/worker.ts`
2. Claim jobs with DB lease + lock (`FOR UPDATE SKIP LOCKED`).
3. Stage-based execution with checkpoint resume.
4. Heartbeat updates and retry backoff.
5. Mark terminal state + structured error payload.
### D. Dashboard
1. Add feature:
- `apps/dashboard/src/lib/features/course-import/*`
2. Add importer modal on org courses page.
3. Poll `GET /jobs/:jobId` until terminal state.
4. On completion, refresh courses and navigate to imported course.
## Provider Plan: Teachable
### Phase T1: Connection + Auth
1. Support API key connection (org-scoped credentials).
2. Validation endpoint to verify credentials.
3. Save encrypted credentials in org integration config.
### Phase T2: Source Discovery
1. List source courses.
2. Fetch source course details:
- modules/lectures
- quizzes/questions if exposed
- media URLs and attachments
### Phase T3: Mapping
1. Map Teachable course -> internal normalized model:
- `course`, `sections`, `lessons`, `exercises`
2. Build fallback rules for missing fields.
3. Generate deterministic slug if needed.
### Phase T4: Import Execution
1. Create group + course shell.
2. Insert sections/lessons in chunked batches.
3. Insert exercises/questions/options in chunked batches.
4. Store warnings for unsupported Teachable entities.
### Teachable Acceptance Criteria
1. Admin can connect Teachable and preview course counts.
2. Import completes for a representative course with sections and lessons.
3. Unsupported entities are logged as warnings, not fatal errors.
## Provider Plan: Thinkific
### Phase K1: Connection + Auth
1. Support `X-Auth-API-Key` and `X-Auth-Subdomain`.
2. Validate credentials + tenant reachability.
### Phase K2: Source Discovery
1. List courses/products available for import.
2. Pull curriculum/chapters/lessons and quiz objects where available.
### Phase K3: Mapping
1. Map Thinkific objects to normalized model.
2. Resolve product/course model differences via explicit mapping rules.
3. Capture unsupported types in warning map.
### Phase K4: Import Execution
1. Reuse shared importer pipeline and chunked writes.
2. Track object-level import counts and warnings in `summary`.
### Thinkific Acceptance Criteria
1. Admin can preview and import at least one multi-chapter course.
2. Imported ordering matches source ordering.
3. Job can resume from checkpoint after forced worker restart.
## Provider Plan: CSV
### Phase C1: CSV Spec (Platform-Agnostic)
1. Define required template:
- `course.csv`
- `sections.csv`
- `lessons.csv`
- `exercises.csv`
- `questions.csv`
- `options.csv`
2. Use `external_id` for parent-child linking.
3. Publish template download from dashboard.
### Phase C2: Parser + Validator
1. Parse ZIP/CSV with strict header validation.
2. Validate row-level integrity:
- missing parents
- invalid enum values
- duplicate `external_id`
- unsupported question types
3. Produce per-row error report in job logs.
### Phase C3: Import Execution
1. Convert CSV rows to normalized model.
2. Import with chunking and checkpointing.
3. Partial failure policy:
- fail-fast on schema errors
- continue-with-warning on optional fields
### CSV Acceptance Criteria
1. User can download template, upload filled ZIP, and preview row counts.
2. Invalid rows return actionable row/column errors.
3. Valid files import deterministically with stable ordering.
## Provider Plan: SCORM ZIP
### Phase S1: Package Intake
1. Accept ZIP upload and verify `imsmanifest.xml`.
2. Detect SCORM version (1.2 or 2004).
3. Enforce size limits and safe extraction constraints.
### Phase S2: Manifest Parsing
1. Parse organizations/items/resources from manifest.
2. Build normalized structure from SCO hierarchy.
3. Capture launch entrypoints and asset references.
### Phase S3: Import Execution
1. Create course/sections/lessons from manifest hierarchy.
2. Attach SCO launch package as lesson asset/content reference.
3. Preserve sequence/order from manifest.
### Phase S4: Runtime Boundary (v1)
1. Provide launchable SCORM content references.
2. Do not implement full runtime event/grade sync in v1.
3. Mark this explicitly in UI copy and docs.
### SCORM Acceptance Criteria
1. Valid SCORM ZIP imports as a structured course.
2. Learner can open imported SCORM lesson launch URL.
3. Invalid manifests fail with clear parser errors.
## Dashboard UX Plan
1. Import modal flow:
- pick provider
- connect/credential step (if needed)
- source selection or file upload
- preview summary
- start import job
2. Job progress view:
- status badge
- stage label
- progress bar
- warnings/errors panel
- retry/cancel actions
3. Completion:
- success snackbar
- deep link to imported course
## Testing Plan
1. Unit:
- adapter mapping functions
- CSV validators
- SCORM manifest parser
2. Integration:
- create/retry/cancel job endpoints
- worker claim/lease/recovery logic
3. E2E:
- start import from dashboard
- observe progress
- verify imported course structure
## Risks and Mitigations
1. Source API shape drift:
- versioned adapter layer + contract tests
2. Large imports timing out:
- async worker, chunking, heartbeat lease
3. Duplicate imports:
- idempotency keys and active-job uniqueness guard
4. Bad input files:
- strict validation and detailed row-level errors
## Build/Verification Commands
1. `pnpm --filter @cio/utils build`
2. `pnpm --filter @cio/db build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/dashboard build`
## Suggested First Sprint Slice
1. Shared foundation (schema + queries + worker skeleton + endpoints)
2. Teachable provider end-to-end
3. Dashboard import modal + job polling
+554
View File
@@ -0,0 +1,554 @@
# Course Templates PRD
## Purpose
Enable instructors to create reusable course blueprints (templates) that can be used to quickly spin up new courses with pre-built structure, content, and settings. Templates reduce repetitive setup work and help organizations standardize course quality.
## Problem Statement
Currently, the only way to reuse course content in ClassroomIO is the clone feature, which creates a full copy of a specific course. This has limitations:
- **No discoverability**: There is no way to browse reusable starting points when creating a course. Instructors must know which existing course to clone.
- **No separation of blueprint vs live course**: Cloned courses immediately become live courses with active status, mixing templates with real courses in the listing.
- **No selective content inclusion**: Clone copies everything — there is no way to include structure but exclude detailed content, or include exercises but exclude lesson notes.
- **No organizational template library**: Organizations cannot curate a set of approved course structures for their instructors to use.
- **`isTemplate` field is unused**: The `course` table already has an `isTemplate` boolean (defaults to `true`), but it is never meaningfully filtered on — every course has it set to `true`.
## Confirmed Decisions
1. **Templates are courses**: A template is a `course` row with `isTemplate = true` and `status = 'TEMPLATE'`. No new content tables are needed. The existing course editor is reused for building templates.
2. **Org-scoped**: Templates belong to an organization (via the existing `group` → `organizationId` relationship). Each org has its own template library.
3. **Built on clone infrastructure**: Creating a course from a template reuses the existing `cloneCourse()` service with modifications to mark the result as a real course (`isTemplate = false`).
4. **Templates are never published**: Templates do not appear in public course listings, student-facing pages, or enrollment flows. They are instructor-only blueprints.
5. **Non-destructive**: Deleting a template does not affect courses already created from it. Courses created from a template become fully independent copies.
6. **Selective content**: When saving an existing course as a template, instructors can choose which content types to include (structure, lesson content, exercises, settings).
## Current-State Audit
| Capability | Current State | Notes |
| --- | --- | --- |
| `isTemplate` field | Exists on `course` table, defaults to `true` | Never filtered on — effectively unused |
| Course cloning | Full deep-copy via `cloneCourse()` service | Copies sections, lessons, exercises, questions, options, translations |
| Clone UI | "Clone" option in course card dropdown menu | Component: `copy-course-modal.svelte` |
| Clone endpoint | `POST /course/:courseId/clone` | Accepts `title`, `description`, `slug`, `organizationId` |
| Course creation | Two-step modal: type selection → title/description | Component: `new-course-modal.svelte` |
| Exercise templates | Separate `exercise_template` table | Existing pattern for template-based creation at exercise level |
| Course listing filter | Queries `getOrgCourses()` with filters for title, tags, sort | No filter for `isTemplate` or `status = 'TEMPLATE'` |
| Course status | Text field, default `'ACTIVE'` | Used for soft delete (`'DELETED'`), no `'TEMPLATE'` value |
## Data Sources Checked
- `packages/db/src/schema.ts` — course table definition, `isTemplate` field
- `apps/api/src/services/course/clone.ts` — full clone implementation
- `apps/api/src/services/course/course.ts` — course creation flow
- `apps/api/src/routes/course/course.ts` — course routes including clone
- `apps/dashboard/src/lib/features/course/components/new-course-modal.svelte` — creation UI
- `apps/dashboard/src/lib/features/course/components/copy-course-modal.svelte` — clone UI
- `apps/dashboard/src/lib/features/course/api/course.svelte.ts` — frontend course API
- `apps/dashboard/src/lib/features/course/api/course-clone.svelte.ts` — frontend clone API
- `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte` — course listing page
- `packages/db/src/queries/course/course.ts` — course queries
- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` — org navigation
## Product Goals
1. Instructors can save any existing course as a reusable template with selective content inclusion.
2. Instructors can create templates from scratch using the existing course editor.
3. When creating a new course, instructors can choose to start from a template instead of a blank course.
4. Organizations have a template library (tab on the courses page) where templates are managed.
5. Templates are cleanly separated from live courses in all listings and queries.
6. Courses created from templates are fully independent — no ongoing link or sync.
7. The existing course creation and clone flows remain unchanged for users who do not use templates.
## Non-Goals (v1)
- Platform-wide template marketplace (orgs sharing templates publicly).
- ClassroomIO-provided starter templates (curated by the platform team).
- AI-generated templates (e.g., "Generate a template for a 4-week React course").
- Template versioning or change tracking.
- Syncing updates from a template to courses already created from it.
- Template access control (e.g., restricting which instructors can use which templates).
- Template analytics beyond usage count.
## UX Flow
### Flow 1: Creating a Template from an Existing Course
**Entry points:**
- Course card dropdown menu → "Save as Template" (alongside existing "Clone" option)
- Course Settings page → "Save as Template" button
**Steps:**
1. User clicks "Save as Template."
2. A modal appears with:
- **Template name** — pre-filled with `"{course title} Template"`, editable.
- **Description** — pre-filled from course description, editable.
- **Category** — optional free-text or select from existing categories (e.g., "Onboarding", "Compliance", "Technical").
- **What to include** — checkboxes:
- Sections & structure (always on, disabled)
- Lesson content (notes, videos, documents) — default on
- Exercises & quizzes — default on
- Course settings (grading, certificates, metadata) — default on
- Landing page content — default off
- Info note: "Student data, submissions, and enrollments are never included."
3. User confirms → system clones the course into a new `course` row with `isTemplate = true`, `status = 'TEMPLATE'`, `isPublished = false`.
4. Success snackbar: "Template created" with a link to the templates tab.
### Flow 2: Creating a Template from Scratch
**Entry point:** Templates tab on the courses page → "Create Template" button.
**Steps:**
1. User clicks "Create Template."
2. Same two-step modal as course creation (type selection → title/description), but the resulting course is marked as a template.
3. Navigates to the course editor with a "Template" badge in the header.
4. User builds out the structure (sections, lessons, exercises) using the existing editor.
5. Template never appears in student-facing listings.
### Flow 3: Creating a Course from a Template
**Entry point:** Existing "Create Course" button on the courses page.
**Steps:**
1. User clicks "Create Course."
2. **Step 1 — Choose starting point** (replaces the current type selection step):
- Two cards: **"Blank Course"** and **"From Template"**.
- "Blank Course" proceeds to the existing flow (type → title/description).
- "From Template" opens the template picker (step 1b).
3. **Step 1b — Template picker:**
- Search bar to filter templates by name.
- Category filter tabs (if categories exist).
- Grid of template cards showing: name, description snippet, lesson count, exercise count, course type badge.
- Each card has a "Preview" action that expands a panel showing the full section/lesson outline (read-only).
- User selects a template and clicks "Use Template."
4. **Step 2 — Customize:**
- Title — pre-filled from template, editable.
- Description — pre-filled from template, editable.
- Type — inherited from template, overridable (Live Class / Self-paced).
- Subtitle: "Based on: {template name}".
5. User clicks "Create Course" → system clones the template into a real course (`isTemplate = false`, `status = 'ACTIVE'`) → navigates to the new course editor with all template content pre-populated.
### Flow 4: Managing Templates
**Entry point:** Courses page → "Templates" tab.
The courses page (`/org/[slug]/courses`) gets a tab bar: **[My Courses]** | **[Templates]**.
**Templates tab features:**
- Grid of template cards with: name, description, course type, lesson count, exercise count, usage count ("Used N times").
- Card dropdown menu: Edit, Duplicate, Delete.
- "Create Template" button.
- **Edit**: Opens the course editor with a "Template" badge. Same editing experience as a real course.
- **Duplicate**: Clones the template into another template.
- **Delete**: Soft-deletes the template (`status = 'DELETED'`). Confirmation dialog: "This will not affect courses already created from this template."
## Technical Design
### Data Model Changes
#### Modified Table: `course`
1. **Fix `isTemplate` default** from `true` to `false`. New courses should not be flagged as templates by default.
2. **Add `templateSourceId`** — nullable UUID FK to `course.id`. Tracks which template a course was created from. Used for usage count analytics ("12 courses created from this template").
3. **Add `templateCategory`** — nullable varchar. Optional category for organizing templates in the picker.
```typescript
// packages/db/src/schema.ts — additions to course table
isTemplate: boolean('is_template').default(false), // changed from true to false
templateSourceId: uuid('template_source_id'),
templateCategory: varchar('template_category'),
// Add to constraints:
foreignKey({
columns: [table.templateSourceId],
foreignColumns: [table.id],
name: 'course_template_source_id_fkey'
}).onDelete('set null')
```
No new tables needed. Templates are courses filtered by `isTemplate = true` and `status = 'TEMPLATE'`.
#### Backward Compatibility
Existing courses all have `isTemplate = true` due to the current default. A data migration will set `isTemplate = false` for all existing courses (they are real courses, not templates). The migration is safe because the field is not currently used in any queries or business logic.
### Migration
Create migration file: `packages/db/src/migrations/XXXX_add_course_templates.sql`
```sql
-- 1. Fix isTemplate for all existing courses (they are real courses, not templates)
UPDATE course SET is_template = false WHERE is_template = true;
-- 2. Change default for is_template to false
ALTER TABLE course ALTER COLUMN is_template SET DEFAULT false;
-- 3. Add template_source_id column
ALTER TABLE course ADD COLUMN template_source_id UUID REFERENCES course(id) ON DELETE SET NULL;
-- 4. Add template_category column
ALTER TABLE course ADD COLUMN template_category VARCHAR(255);
-- 5. Add index for template queries
CREATE INDEX idx_course_is_template ON course(is_template) WHERE is_template = true;
-- 6. Add index for template usage count
CREATE INDEX idx_course_template_source_id ON course(template_source_id) WHERE template_source_id IS NOT NULL;
```
### Validation Layer
File: `packages/utils/src/validation/course/template.ts`
```typescript
import { z } from 'zod';
export const ZSaveAsTemplate = z.object({
title: z.string().min(1).max(255),
description: z.string().min(1),
category: z.string().max(100).optional(),
includeLessonContent: z.boolean().default(true),
includeExercises: z.boolean().default(true),
includeSettings: z.boolean().default(true),
includeLandingPage: z.boolean().default(false)
});
export type TSaveAsTemplate = z.infer<typeof ZSaveAsTemplate>;
export const ZCreateFromTemplate = z.object({
templateId: z.string().uuid(),
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(),
organizationId: z.string().min(1)
});
export type TCreateFromTemplate = z.infer<typeof ZCreateFromTemplate>;
export const ZCreateTemplate = z.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']),
category: z.string().max(100).optional(),
organizationId: z.string().min(1)
});
export type TCreateTemplate = z.infer<typeof ZCreateTemplate>;
export const ZUpdateTemplate = z.object({
title: z.string().min(1).max(255).optional(),
description: z.string().min(1).optional(),
category: z.string().max(100).nullable().optional()
});
export type TUpdateTemplate = z.infer<typeof ZUpdateTemplate>;
```
Export from: `packages/utils/src/validation/course/index.ts`
### Query Layer
File: `packages/db/src/queries/course/template.ts`
Functions:
- `getOrgTemplates(organizationId)` — list all templates for an org. Returns templates with lesson count, exercise count, and usage count (number of courses with `templateSourceId` pointing to this template). Ordered by `createdAt` desc.
- `getTemplateById(templateId)` — single template with full details and usage count.
- `getTemplateUsageCount(templateId)` — count of courses created from this template.
Export from: `packages/db/src/queries/course/index.ts`
### Service Layer
File: `apps/api/src/services/course/template.ts`
#### `saveAsTemplate(courseId, userId, data: TSaveAsTemplate)`
- Fetch the source course.
- Clone it via a modified `cloneCourse()` that respects the `include*` flags:
- Always clone sections (structure).
- Skip lesson content (notes, videos, documents) if `includeLessonContent = false` — create lessons with title and order only.
- Skip exercises if `includeExercises = false`.
- Skip metadata fields if `includeSettings = false`.
- Skip landing page fields (bannerImage, metadata.videoUrl, etc.) if `includeLandingPage = false`.
- Set `isTemplate = true`, `status = 'TEMPLATE'`, `isPublished = false` on the new course.
- Set `templateCategory` if provided.
- Return the new template.
#### `createTemplate(userId, data: TCreateTemplate)`
- Create a new course with `isTemplate = true`, `status = 'TEMPLATE'`, `isPublished = false`.
- Follow the same transaction as `createCourse()` (create group → course → add creator as TUTOR).
- Set `templateCategory` if provided.
- Return the new template.
#### `createFromTemplate(templateId, userId, data: TCreateFromTemplate)`
- Fetch the template. Verify `isTemplate = true`.
- Clone via `cloneCourse()` with all content included.
- Override: `isTemplate = false`, `status = 'ACTIVE'`, `isPublished = false`.
- Override: `title`, `description`, `type` from user input.
- Set `templateSourceId = templateId` on the new course.
- Return the new course.
#### `deleteTemplate(templateId)`
- Verify the course is a template (`isTemplate = true`).
- Soft delete: set `status = 'DELETED'`.
- Does not affect courses with `templateSourceId = templateId`.
### Route Layer
File: `apps/api/src/routes/course/template.ts`
All routes nested under `/course/template` and require `authMiddleware` + admin/tutor role.
| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/course/template` | List org templates (query param: `organizationId`) |
| `POST` | `/course/template` | Create blank template |
| `GET` | `/course/template/:templateId` | Get template details |
| `PUT` | `/course/template/:templateId` | Update template metadata |
| `DELETE` | `/course/template/:templateId` | Soft-delete template |
| `POST` | `/course/:courseId/save-as-template` | Save existing course as template |
| `POST` | `/course/template/:templateId/create-course` | Create new course from template |
| `POST` | `/course/template/:templateId/duplicate` | Duplicate a template |
#### Route Registration
```typescript
// apps/api/src/routes/course/index.ts
export * from './template';
// Registration in course router
.route('/template', templateRouter)
```
### Modified Existing Queries
#### `getOrgCourses(organizationId, ...)`
Add filter: `WHERE is_template = false AND status != 'TEMPLATE'` to exclude templates from the normal course listing. This ensures the "My Courses" tab only shows real courses.
### Error Codes
Add to `apps/api/src/utils/errors.ts`:
```typescript
TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND',
NOT_A_TEMPLATE: 'NOT_A_TEMPLATE',
```
### Build Verification
```bash
pnpm --filter @cio/utils build
pnpm --filter @cio/db build
pnpm --filter @cio/api build
```
## Frontend Plan (Dashboard)
### Request Types
File: `apps/dashboard/src/lib/features/course/utils/types.ts`
```typescript
export type ListTemplatesRequest = typeof classroomio.course.template.$get;
export type CreateTemplateRequest = typeof classroomio.course.template.$post;
export type GetTemplateRequest = typeof classroomio.course.template[':templateId']['$get'];
export type UpdateTemplateRequest = typeof classroomio.course.template[':templateId']['$put'];
export type DeleteTemplateRequest = typeof classroomio.course.template[':templateId']['$delete'];
export type SaveAsTemplateRequest = typeof classroomio.course[':courseId']['save-as-template']['$post'];
export type CreateFromTemplateRequest = typeof classroomio.course.template[':templateId']['create-course']['$post'];
export type ListTemplatesSuccess = Extract<InferResponseType<ListTemplatesRequest>, { success: true }>;
export type CourseTemplate = ListTemplatesSuccess['data'][number];
```
### API Class
File: `apps/dashboard/src/lib/features/course/api/template.svelte.ts`
```typescript
class TemplateApi extends BaseApiWithErrors {
templates = $state<CourseTemplate[]>([]);
loading = $state(false);
async list(organizationId: string) { ... }
async create(data: TCreateTemplate) { ... }
async update(templateId: string, data: TUpdateTemplate) { ... }
async remove(templateId: string) { ... }
async saveAsTemplate(courseId: string, data: TSaveAsTemplate) { ... }
async createFromTemplate(templateId: string, data: TCreateFromTemplate) { ... }
async duplicate(templateId: string) { ... }
}
```
### New/Modified Components
#### `save-as-template-modal.svelte`
Location: `apps/dashboard/src/lib/features/course/components/save-as-template-modal.svelte`
Modal with: title, description, category, content inclusion checkboxes, confirm button.
#### `template-picker.svelte`
Location: `apps/dashboard/src/lib/features/course/components/template-picker.svelte`
Grid of template cards with search, category filters, preview panel, and "Use Template" action.
#### Modified: `new-course-modal.svelte`
Add a new initial step: "Blank Course" vs "From Template" card selection. "Blank" proceeds to existing flow. "From Template" shows `template-picker.svelte`, then proceeds to the customize step.
#### Modified: Course card dropdown
Add "Save as Template" option alongside existing "Clone" option.
#### Modified: Courses page
Add tab bar: **[My Courses]** | **[Templates]**. The templates tab renders the template management grid.
### Template Badge
When editing a template in the course editor, show a persistent badge in the header: a colored pill reading "Template" (e.g., `bg-blue-100 text-blue-800`). This makes it clear the user is editing a blueprint, not a live course.
### Routes
| Route | Page |
| --- | --- |
| `/org/[slug]/courses?tab=templates` | Templates tab on courses page (no new route file — tab state via query param) |
No new route files needed — the templates tab lives on the existing courses page, controlled by a `tab` query parameter.
### Translations
File: `apps/dashboard/src/lib/utils/translations/en.json`
Add keys under `course.templates`:
```json
{
"course": {
"templates": {
"title": "Templates",
"create": "Create Template",
"save_as_template": "Save as Template",
"from_template": "From Template",
"blank_course": "Blank Course",
"blank_course_description": "Start with an empty course",
"from_template_description": "Choose from your template library",
"choose_starting_point": "How would you like to start?",
"choose_template": "Choose a template",
"customize_course": "Customize your course",
"based_on": "Based on: {name}",
"use_template": "Use Template",
"preview": "Preview",
"template_name": "Template Name",
"category": "Category",
"what_to_include": "What to include",
"sections_structure": "Sections & structure",
"lesson_content": "Lesson content (notes, videos, documents)",
"exercises_quizzes": "Exercises & quizzes",
"course_settings": "Course settings (grading, certificates)",
"landing_page_content": "Landing page content",
"student_data_note": "Student data, submissions, and enrollments are never included.",
"template_created": "Template created",
"view_templates": "View in Templates",
"template_badge": "Template",
"used_times": "Used {count} times",
"no_templates": "No templates yet. Save an existing course as a template or create one from scratch.",
"delete_confirm": "This will not affect courses already created from this template.",
"lessons_count": "{count} lessons",
"exercises_count": "{count} exercises",
"search_templates": "Search templates..."
}
}
}
```
### Frontend Build Verification
```bash
pnpm --filter @cio/dashboard build
```
## Implementation Order
### Phase 1: Database + Schema
1. Update `isTemplate` default from `true` to `false` in schema.
2. Add `templateSourceId` and `templateCategory` columns to `course` table in schema.
3. Create migration: fix existing data, add columns, add indexes.
4. Run `pnpm --filter @cio/db build`.
### Phase 2: Validation + Queries
1. Create `packages/utils/src/validation/course/template.ts` with all Zod schemas.
2. Export from `packages/utils/src/validation/course/index.ts`.
3. Create `packages/db/src/queries/course/template.ts` with query functions.
4. Update `getOrgCourses` to exclude templates.
5. Export from `packages/db/src/queries/course/index.ts`.
6. Run `pnpm --filter @cio/utils build` and `pnpm --filter @cio/db build`.
### Phase 3: Service + Routes
1. Create `apps/api/src/services/course/template.ts` with all service functions.
2. Refactor `cloneCourse()` to accept an options object for selective content cloning.
3. Create `apps/api/src/routes/course/template.ts` with all route handlers.
4. Register template router in course route index.
5. Add error codes to `apps/api/src/utils/errors.ts`.
6. Run `pnpm --filter @cio/api build`.
### Phase 4: Frontend — Template Management
1. Add request types to `apps/dashboard/src/lib/features/course/utils/types.ts`.
2. Create `apps/dashboard/src/lib/features/course/api/template.svelte.ts` API class.
3. Add translation keys to `en.json`.
4. Add "Templates" tab to courses page.
5. Create template card component for the grid.
6. Add "Save as Template" to course card dropdown and course settings.
7. Create `save-as-template-modal.svelte`.
8. Run `pnpm --filter @cio/dashboard build`.
### Phase 5: Frontend — Create from Template
1. Modify `new-course-modal.svelte` to add "Blank" vs "From Template" step.
2. Create `template-picker.svelte` with search, categories, preview.
3. Add customize step with template-prefilled fields.
4. Add "Template" badge to course editor header when editing a template.
5. Run `pnpm --filter @cio/dashboard build`.
## Acceptance Criteria
1. Instructor can save an existing course as a template with selective content inclusion.
2. Instructor can create a blank template from the templates tab.
3. Templates appear in the "Templates" tab and do not appear in the "My Courses" tab.
4. Instructor can create a new course from a template via the "Create Course" flow.
5. Courses created from templates are fully independent — editing the template does not affect the course and vice versa.
6. Template cards show lesson count, exercise count, and usage count.
7. Template picker supports search and category filtering.
8. Template preview shows the section/lesson outline in a read-only view.
9. Existing course creation flow ("Blank Course") is unchanged.
10. Existing clone functionality is unchanged.
11. Deleting a template does not affect courses created from it.
12. Templates are not visible to students in any listing or enrollment flow.
13. Editing a template uses the same course editor with a "Template" badge.
14. All existing courses have `isTemplate = false` after migration.
15. All new UI strings use translation keys (no hardcoded English).
16. `pnpm --filter @cio/api build` and `pnpm --filter @cio/dashboard build` pass after implementation.
## Risks and Mitigations
- **Risk**: Changing `isTemplate` default from `true` to `false` and bulk-updating existing courses could cause issues if any code relies on `isTemplate = true`.
- **Mitigation**: Codebase search confirms `isTemplate` is never used in filtering or conditional logic — it is only copied during clone. The migration is safe.
- **Risk**: Refactoring `cloneCourse()` to support selective content could break existing clone functionality.
- **Mitigation**: The refactor adds an optional `options` parameter. When not provided, all content is cloned (current behavior). Existing clone callers are not affected.
- **Risk**: Templates accumulate and become hard to find.
- **Mitigation**: Category filtering and search are built into the template picker from v1. Categories can be extended to tags in a future iteration.
## Future Considerations
- **Platform starter templates**: ClassroomIO-provided templates shown in a separate "ClassroomIO Templates" section of the picker.
- **AI-generated templates**: "Generate with AI" as a third option in the course creation flow alongside "Blank" and "From Template."
- **Template marketplace**: Orgs opt-in to share templates publicly.
- **Template versioning**: Track changes and allow courses to "pull updates" from the source template.
- **Template access control**: Restrict which instructors can use or edit certain templates.
- **Cohort integration**: When course cohorts ship, templates could include default cohort configuration.
+684
View File
@@ -0,0 +1,684 @@
# Course Widget Embed PRD (Org Widgets)
## Purpose
Ship an organization-scoped widget system so admins can embed selected courses on external websites from `/org/[slug]/widgets`, with a visual editor and lightweight runtime.
## Confirmed Decisions (2026-02-17)
1. Editor route is in dashboard org context: `/org/[slug]/widgets` (user-facing equivalent: `/org/id/widgets`).
2. Embed runtime must be plain Svelte (no SvelteKit in the embed runtime path) for minimal JS payload.
3. Widget rendering supports multiple layouts:
- cards
- table
- primary single course
- carousel
- tag-filtered list (tags as top badges)
4. Widget authoring UI opens an editor with left panel sections:
- `Select Courses`
- `Widgets`
- `Design`
5. Published widgets deploy to Cloudflare-backed static hosting/edge delivery (R2 + Workers).
6. Dashboard editing imports the widget package in dev mode for real preview parity.
7. Dashboard preview does not call a preview endpoint; it uses already-fetched dashboard state and passes payload into an iframe.
8. Public embed fetches computed widget payload (courses + design config) from server endpoints.
## Problem Statement
Organizations currently show courses mainly on their own landing pages. There is no first-class way to publish reusable, brandable, copy-paste course widgets for external websites.
This creates gaps:
- No reusable embed snippet per organization.
- No dashboard-managed widget lifecycle (draft, publish, version rollback).
- No lightweight runtime optimized for third-party pages.
- No structured design controls for layout/theme/branding.
## Product Goals
1. Let org admins create and publish embeddable course widgets.
2. Keep embed runtime lightweight and isolated from host-site CSS/JS conflicts.
3. Reuse existing course and tag data models for content selection/filtering.
4. Provide clear plan-gated design controls (free vs paid capabilities).
5. Support versioned publishing with safe rollbacks.
## Non-Goals (v1)
- Full website/page builder replacement.
- Server-rendered widget runtime.
- Cross-org shared widget templates marketplace.
- Arbitrary third-party JS execution inside widgets.
## Data Sources Checked
- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`
- `apps/dashboard/src/routes/org/[slug]/courses/+page.server.ts`
- `apps/dashboard/src/routes/org/[slug]/tags/+page.server.ts`
- `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
- `apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte`
- `apps/dashboard/src/lib/features/course/components/card.svelte`
- `apps/api/src/routes/organization/organization.ts`
- `apps/api/src/services/organization.ts`
- `packages/db/src/schema.ts`
- `packages/db/src/queries/course/course.ts`
- `packages/db/src/queries/tag/tag.ts`
- `packages/utils/src/plans/constants.ts`
- `packages/course-app/README.md`
## User Roles
### Admin (`ROLE.ADMIN`)
- Create/edit/delete/archive widgets.
- Select courses and widget layout.
- Configure design controls and plan-gated options.
- Publish widget versions and rollback.
- Copy embed snippet.
### Tutor (`ROLE.TUTOR`)
- Create/edit widget drafts and use live preview.
- No publish, rollback, or destructive lifecycle actions in v1.
### Student/Public
- No dashboard access.
- Consume published embed on external sites.
## Functional Requirements
### 1. Widget Lifecycle
- Create widget with:
- `name`
- `layoutType`
- selection mode (`manual` course selection or `published courses`)
- initial design config
- Status model:
- `DRAFT`
- `PUBLISHED`
- `ARCHIVED`
- Save model:
- manual save only (`Save` button)
- no autosave in v1
- Publish creates immutable version snapshot.
- Rollback can point latest published to a previous version.
### 2. Widget Editor UX (`/org/[slug]/widgets/:widgetId`)
- Left panel tabs:
- `Select Courses`
- `Widgets`
- `Design`
- Main panel:
- live preview using the same shared render package as embed
- desktop/mobile preview toggle
- embed code tab
- Live preview behavior:
- every design/content change updates a local draft state
- dashboard uses already-fetched state (courses/tags/widget draft) to build preview payload
- preview payload is passed down through iframe (`postMessage`) to preview runtime
- iframe mounts the same widget component contract used by public embed
- changes are preview-only until user clicks `Save`
### 3. Select Courses
- Source modes:
- manual selected courses
- all published org courses
- Filters:
- tag include/exclude using existing org tags
- search by course title
- Ordering:
- manual order
- newest
- title A-Z
### 4. Widget Layout Types
- `cards`
- `table`
- `primary_course`
- `carousel`
- `tag_filter`
Each layout supports required shared props:
- `showCourseImage`
- `showCourseTypeBadge`
- `showLessonsCount`
- `showPrice`
- `showDescriptionExcerpt`
- `ctaLabel`
### 5. Design Controls
Design panel is grouped as:
#### Content
- limit courses toggle + max count
- load more label
- hide load more toggle
- show border toggle
- border width
- shadow size + style + color
- border radius scale
- show metadata toggles (lessons/type/price)
- shorten long description toggle
- show highlighted/featured style toggle
#### Colors
- primary color
- background color
- text color
- badge/rating color
- border color
- highlight color
#### Typography
- font family selector
- font size scale
#### Filters
- add/remove tag filter rows
- top badge style for tag filter layout
#### Branding
- `Show Classroomio Powered By` toggle
#### Advanced
- custom CSS textarea (sanitized + size-limited)
### 6. Plan Gating
- Free (`PLAN.BASIC`):
- **1 preset theme only** (default Classroomio theme)
- branding forced on
- custom CSS disabled
- Paid (`PLAN.EARLY_ADOPTER`, `PLAN.ENTERPRISE`):
- **All preset themes available** (3-5 curated themes)
- full color controls (custom colors)
- branding toggle available
- custom CSS enabled
### 7. Public Embed Contract
- Copy-paste script embed:
- `<script async src="https://widgets.classroomio.com/v1/loader.js" data-widget="wgt_xxx"></script>`
- Loader pulls published widget payload via public key and mounts Svelte widget.
- Host page should not require framework dependencies.
- Payload shape must match dashboard preview payload contract.
### 8. Runtime Constraints (Lightweight Requirement)
- Plain Svelte runtime package (`packages/widgets`) compiled in library mode (Vite `build.lib`).
- No SvelteKit runtime in embed bundle.
- Use style isolation: **Shadow DOM custom element** (`<widget-root>`) with `:host` selector support.
- Performance budgets:
- loader <= 3KB gzip
- base runtime <= 35KB gzip (excluding fetched data)
- first render <= 1.2s on mid-tier 4G for <= 6 courses
- Accessibility:
- All interactive elements keyboard accessible
- ARIA labels on course cards (`aria-label="Course: ${title}"`)
- Alt text on course images
- Focus visible indicators
### 9. Security and Safety
- Public widget key must be non-guessable (format: `wgt_<base58>`).
- Custom CSS sanitized with `css-tree` parser (whitelist approach):
- Max size: 5KB
- Reject: `@import`, `javascript:`, expression(), behavior:, binding:, `-moz-binding`
- Reject properties: `position: fixed` (can overlay host page maliciously)
- Allowed: standard visual styles (color, spacing, typography, flex/grid layout)
- Public endpoint returns only publish-approved fields (no internal org data).
- Rate limit public widget payload endpoint: 100 req/min per IP, 1000 req/min per widget key.
- iframe origin validation: strict `targetOrigin` matching dashboard domain.
### 10. Analytics (Schema Reserved for Phase 2)
**Deferred to Phase 2**, but schema defined for forward compatibility:
```typescript
// Table: widget_analytics_event
{
id: string;
widget_id: string;
widget_version_id: string;
event_type: 'impression' | 'cta_click' | 'filter_interaction';
course_id?: string; // for cta_click events
filter_tag_id?: string; // for filter_interaction events
session_id: string; // anonymous session fingerprint
timestamp: Date;
user_agent_hash: string; // privacy-preserving hash
country_code?: string; // from CF-IPCountry header
}
```
- Ingestion: Batch insert every 30 seconds or 1000 events
- Retention: 90 days raw, aggregated forever
- v1: No events endpoint; schema created but not populated
### 11. Shared Payload Contract (Hard Requirement)
- One canonical payload schema defines widget data:
- selected courses
- resolved ordering/filters
- normalized design tokens and defaults
- plan-gated fields
- Dashboard preview builds payload client-side from already-fetched state and passes it to iframe.
- Public embed builds/fetches payload server-side from persisted widget config and course queries.
- The embed renderer accepts only this payload schema + minimal runtime options.
- No separate dashboard-only renderer data shape is allowed.
### 11. Cloudflare Publish Pipeline (Phase E)
#### Artifact Generation
1. **Build Trigger**: API call `POST /organization/widgets/:widgetId/publish`
2. **Build Process**:
```
a. API receives publish request
b. Compute final payload with resolved courses + design config
c. Trigger build worker (Cloudflare Worker or GitHub Actions)
d. Build @cio/widgets package with embedded design tokens
e. Generate hashed filenames (e.g., `widget.a3f2b1c.js`)
f. Upload to R2 bucket: `widgets/{orgId}/{widgetId}/v{version}/`
g. Return manifest URLs to API
h. API saves `artifact_manifest` to `widget_version` table
```
#### Artifact Manifest Format
```json
{
"version": "v1",
"widgetId": "wgt_xxx",
"orgId": "org_yyy",
"createdAt": "2026-02-19T12:00:00Z",
"files": {
"js": {
"url": "https://cdn.classroomio.com/widgets/org_yyy/wgt_xxx/v3/widget.a3f2b1c.js",
"integrity": "sha384-abc123...",
"size": 24567
},
"css": {
"url": "https://cdn.classroomio.com/widgets/org_yyy/wgt_xxx/v3/styles.7e8d9f0.css",
"integrity": "sha384-def456...",
"size": 3456
}
},
"designHash": "sha256:abc..."
}
```
#### Cleanup Policy
- Keep last 10 versions per widget
- Delete older artifacts after 30 days (R2 lifecycle rule)
- Soft-delete in DB (set `deleted_at`), hard-delete after 90 days
#### Delivery Architecture
```
User's Website Cloudflare
| |
|-- script src="loader.js" -----> |-- Worker: validate key
| |-- Check cache (KV)
| |-- Cache miss: fetch from R2
|<-- loader.js -------------------|
| |
|-- loader fetches manifest ----->|-- Return artifact_manifest
| |
|-- loader injects JS/CSS ------->|-- Serve from R2 (immutable cache)
```
---
## Proposed Architecture
### Package and App Boundaries
1. `packages/widgets`
- plain Svelte renderer components
- layout implementations
- shared design token mapping
- build output: ESM + browser bundle
2. `apps/dashboard`
- widget editor UI and preview
- imports renderer from `@cio/widgets` in dev/prod build
3. `apps/api`
- org-protected widget CRUD/publish routes
- public widget payload route
- shared `computeWidgetPayload(...)` service used for public payload routes
4. Cloudflare publish target
- stores versioned JS/CSS artifacts
- serves loader + static assets + cache headers
### Render Data Flow
1. Dashboard editor loads widget draft and related data into state (courses/tags/config).
2. Left panel changes update local draft JSON in state.
3. Dashboard builds preview payload from state and sends it to preview iframe via `postMessage`.
4. Preview iframe mounts `@cio/widgets` renderer with received payload.
5. Publish stores snapshot; public loader requests server-computed payload for published version and mounts same renderer.
### Data Model (Proposed)
1. `widget`
- `id`, `organization_id`, `name`, `status`, `layout_type`
- `selection_mode`, `public_key`, `latest_published_version_id`
- `created_by`, `updated_by`, timestamps
2. `widget_course`
- `widget_id`, `course_id`, `order`
3. `widget_version`
- `id`, `widget_id`, `version`
- `config_snapshot` (jsonb)
- `artifact_manifest` (jsonb: js/css URLs + integrity)
- `published_at`, `published_by`
## API Contract Additions (Target)
### Organization-Scoped (auth + org membership)
1. `GET /organization/widgets`
2. `POST /organization/widgets`
3. `GET /organization/widgets/:widgetId`
4. `PUT /organization/widgets/:widgetId`
5. `DELETE /organization/widgets/:widgetId`
6. `POST /organization/widgets/:widgetId/publish`
7. `POST /organization/widgets/:widgetId/rollback`
### Public
1. `GET /widgets/:publicKey/payload`
2. `POST /widgets/:publicKey/events` (optional in v1, can be deferred)
## UX Flow
1. Admin opens `/org/[slug]/widgets`.
2. Clicks `Create Widget`.
3. Editor opens with left tabs (`Select Courses`, `Widgets`, `Design`).
4. Admin previews and adjusts design.
5. Admin publishes, receives embed snippet.
6. External website adds script tag; widget renders courses.
### Plan Gating UX Pattern
- **Free users**: Show paid features as disabled with upgrade tooltip
- Theme selector shows 1 option with "Upgrade to unlock more themes" on hover
- Custom color picker disabled with "Upgrade to customize colors" tooltip
- Custom CSS textarea shows lock icon with upgrade CTA
- "Powered by Classroomio" toggle is locked ON with tooltip
- **Paid users**: Full access to all controls, branding toggle available
## Open Questions (Resolved)
1. ✅ **Free plan color tweaks** – **1 preset theme only** (default). Paid users get all preset themes (3-5) plus custom color picker.
2. ✅ **Events endpoint** – Deferred to Phase 2; schema reserved but not implemented in v1.
3. ✅ **Locale detection** – Deferred to Phase 2; widgets use single-language labels from org default locale in v1.
4. ✅ **CSS isolation** – **Shadow DOM custom element** preferred for strongest isolation. Fallback to scoped classes only if Shadow DOM causes critical theming issues during Phase C testing.
## Additional Architecture Decisions
### Widget Key Format
- Format: `wgt_<base58(16-bytes-random)>`
- Example: `wgt_3J98t1WpEZ73CNmYv`
- Includes implicit typo detection via base58 checksum behavior
- Length: 23 characters (prefix + 16 chars)
### Rollback Mechanics
- Rollback is **pointer-swap**, not append-only
- `latest_published_version_id` points to any previous version
- No new version created on rollback
- Analytics remain tied to version ID (history preserved)
- UI shows version history (read-only) with "Restore to this version" action
### Caching Strategy
- Public payload endpoint: `Cache-Control: public, max-age=60, stale-while-revalidate=300`
- Widget JS/CSS artifacts: `Cache-Control: public, max-age=31536000, immutable`
- Loader script: `Cache-Control: public, max-age=3600` (versioned via query param)
## Expanded TODOs (Priority)
### TODO-2: Iframe Protocol Spec (Detailed)
#### Message Types
```typescript
// Parent → Iframe
type ParentMessage =
| { type: 'WIDGET_PREVIEW_RENDER'; payload: WidgetPayload; requestId: string }
| { type: 'WIDGET_PREVIEW_PING' }; // health check
// Iframe → Parent
type IframeMessage =
| { type: 'WIDGET_PREVIEW_READY'; iframeId: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_RENDERED'; requestId: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_ERROR'; requestId: string; error: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_RESIZE'; height: number; width?: number }
| { type: 'WIDGET_PREVIEW_CTA_CLICK'; courseId: string }; // for preview interactivity
```
#### Payload Envelope
```typescript
interface WidgetPayload {
version: 'v1';
widgetId: string;
layoutType: 'cards' | 'table' | 'primary_course' | 'carousel' | 'tag_filter';
courses: Course[];
design: DesignConfig;
planGatedFields: PlanGatedFields;
timestamp: number; // epoch ms for cache-busting
}
```
#### Security Rules
1. **Origin Validation**:
- Production: `targetOrigin` must match `VITE_DASHBOARD_URL` (e.g., `https://app.classroomio.com`)
- Development: allow `http://localhost:*` with port wildcard
- Never use `*` in production
2. **Message Validation**:
- Reject messages without `type` field
- Reject unknown message types
- Validate payload schema with Zod before processing
3. **iframe Sandboxing**:
- Use `sandbox="allow-scripts"` attribute
- No `allow-same-origin` (prevents cookie access)
#### Reliability Rules
1. **Handshake Flow**:
```
Parent: mount iframe → wait for READY (timeout: 5s)
Iframe: mount complete → send READY
Parent: receive READY → send RENDER with payload
Iframe: render complete → send RENDERED or ERROR
```
2. **Retry Logic**:
- READY timeout: 3 retry attempts, exponential backoff (1s, 2s, 4s)
- RENDERED timeout: 2 retry attempts, show error state in parent
3. **Error Handling**:
- Render errors: Show fallback UI in parent ("Preview unavailable")
- Log error details to console with `[WidgetPreview]` prefix
- Surface user-friendly error in editor UI
#### Sequence Diagram
```
Parent (Dashboard) Iframe (Widget Runtime)
| |
|--- mount iframe ------------>|
| |
|<-- WIDGET_PREVIEW_READY -----|
| (iframe ready) |
| |
|--- WIDGET_PREVIEW_RENDER --->|
| (payload + config) |
| |
|<-- WIDGET_PREVIEW_RESIZE ----|
| (dynamic height) |
| |
|<-- WIDGET_PREVIEW_RENDERED --|
| OR WIDGET_PREVIEW_ERROR |
```
#### Documentation Output
- Spec file: `prd/course-widget-embed/iframe-protocol.md` (this section)
- Implementation: `packages/widgets/src/preview/iframe-bridge.ts` + `apps/dashboard/src/lib/features/widget/preview/iframe-parent.ts`
### TODO-3: Preview Data Freshness (Implementation Details)
#### Freshness States
| State | Indicator | User Action | Behavior |
|-------|-----------|-------------|----------|
| `fresh` | Green dot + "Live" | None | Data synced with server within last 5 minutes |
| `stale` | Yellow dot + "Refresh suggested" | Manual refresh button | Data older than 5 minutes or tab was backgrounded |
| `refreshing` | Spinner + "Syncing..." | Disabled | Fetch in progress |
| `error` | Red dot + "Sync failed" | Retry button | Last fetch failed, using cached data |
#### Implementation Rules
1. **Initial Load**: `+page.server.ts` provides hydrated data with `serverTimestamp`
2. **Stale Detection**:
- Check `Date.now() - lastSyncTime > 5 * 60 * 1000` on mount
- Listen to `visibilitychange` event: when tab becomes visible, check staleness
3. **Background Sync**:
- Use `navigator.sendBeacon` or `fetch` with `keepalive` for refresh
- Debounce refresh calls (minimum 30s between requests)
4. **Warning UX**:
- Non-blocking toast when stale data detected
- Course/tag removed warning appears in preview area (not blocking)
5. **Consistency**:
- Draft state is never auto-refreshed; only source data (courses/tags list)
- User must explicitly refresh to see new courses/tags
#### State Machine
```
initial load
[empty] ----------> [fresh]
|
5min timeout | visibility change
v
[stale] <------.
| |
manual or | | error
auto refresh |
v |
[refreshing] ----'
|
success | | failure
| |
v v
[fresh] [error]
^ |
'-------'
retry
```
---
### TODO-4: CSS Sanitizer Implementation
#### Library Choice
- **Primary**: `css-tree` for parsing + whitelist validation
- **Backup**: `reworkcss` if `css-tree` bundle size exceeds 15KB
#### Validation Rules
```typescript
const CSS_SANITIZER_CONFIG = {
maxSizeBytes: 5120, // 5KB
// Allowed at-rules
allowedAtRules: ['media', 'supports', 'keyframes', '-webkit-keyframes'],
// Blocked at-rules (security)
blockedAtRules: ['import', 'charset', 'namespace', 'font-face', 'page', 'document'],
// Allowed properties (whitelist approach)
allowedProperties: [
// Layout
'display', 'position', 'top', 'right', 'bottom', 'left',
'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
'justify-content', 'align-items', 'align-content', 'gap', 'row-gap', 'column-gap',
'grid', 'grid-template', 'grid-column', 'grid-row', 'grid-area',
// Box model
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border', 'border-radius', 'box-sizing', 'overflow', 'overflow-x', 'overflow-y',
// Visual
'color', 'background', 'background-color', 'background-image', 'background-size',
'background-position', 'background-repeat', 'opacity', 'box-shadow',
'border-color', 'border-width', 'border-style', 'border-top', 'border-right',
// Typography
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
'text-align', 'text-decoration', 'text-transform', 'letter-spacing', 'word-spacing',
'white-space', 'word-break', 'overflow-wrap',
// Effects
'transform', 'transition', 'animation', 'filter', 'backdrop-filter'
],
// Blocked values (regex patterns)
blockedValuePatterns: [
/javascript:/i,
/expression\s*\(/i,
/behavior\s*:/i,
/binding\s*:/i,
/-moz-binding/i,
/url\s*\(\s*["']?\s*data:/i, // data URIs (potential XSS)
],
// Special property validations
propertyValidators: {
'position': (value: string) => value !== 'fixed', // No fixed positioning
'background-image': (value: string) => !value.includes('url') || value.startsWith('url(https://')
}
};
```
#### Error Handling
- Invalid CSS is stripped silently (not rejected entirely)
- User sees warning: "Some CSS rules were removed for security"
- Log sanitized rules to console in dev mode only
---
### TODO-5: Accessibility Requirements (v1)
#### Keyboard Navigation
- Tab order: logical flow through course cards
- Enter/Space: activate CTA buttons
- Escape: close any open modals/filter dropdowns
#### Screen Reader Support
- Course cards: `role="article"` with `aria-labelledby` pointing to title
- CTA buttons: descriptive `aria-label` (e.g., "View Introduction to Python course")
- Filter badges: `aria-pressed` state for active filters
- Live regions: announce filter results changes (`aria-live="polite"`)
#### Visual Requirements
- Focus indicators: 2px solid outline with 2px offset (using `:focus-visible`)
- Color contrast: minimum 4.5:1 for text (validated in design tokens)
- Reduced motion: respect `prefers-reduced-motion` for carousel animations
---
### TODO-6: Bundle Size Enforcement
#### CI Configuration
```yaml
# packages/widgets/bundle-size.json
{
"limits": {
"loader.js": "3kb",
"runtime.js": "35kb",
"runtime.css": "5kb"
},
"failOnViolation": true
}
```
#### Build Script
```bash
#!/bin/bash
# packages/widgets/scripts/check-bundle-size.sh
max_size_loader=$((3 * 1024)) # 3KB
max_size_runtime=$((35 * 1024)) # 35KB
loader_size=$(gzip -c dist/loader.js | wc -c)
runtime_size=$(gzip -c dist/runtime.js | wc -c)
if [ $loader_size -gt $max_size_loader ]; then
echo "❌ Loader exceeds budget: ${loader_size}B > ${max_size_loader}B"
exit 1
fi
if [ $runtime_size -gt $max_size_runtime ]; then
echo "❌ Runtime exceeds budget: ${runtime_size}B > ${max_size_runtime}B"
exit 1
fi
echo "✅ Bundle sizes within budget"
```
## Success Criteria
1. Admin can publish a widget and embed it externally in under 5 minutes.
2. Embed snippet works across static HTML, React, and WordPress hosts.
3. Runtime stays within performance budget and has no host CSS collisions.
4. Support tickets for “embed broken by host CSS” remain near zero.
5. Dashboard live preview matches public embed rendering for the same payload and version.
6. **Accessibility**: Widgets are keyboard-navigable (Tab order, Enter/Space to activate CTAs) and screen-reader friendly (ARIA labels on course cards, alt text on images).
7. **Security**: Zero XSS vectors through custom CSS or malformed payloads in penetration testing.
@@ -0,0 +1,172 @@
# Course Widget Embed Implementation Plan (v1)
## Scope Lock
1. Widget embed runtime is plain Svelte package output (no SvelteKit runtime dependency in embedded widget).
2. Admin + Tutor can edit drafts; publish/rollback/delete remain admin-only.
3. Org-scoped widgets only; no cross-org sharing.
4. Required layout types in v1:
- cards
- table
- primary course
- carousel
- tag filter badges
5. Editor route is `/org/[slug]/widgets` with create/edit flows.
6. Publish target is Cloudflare-hosted static assets + edge delivery.
7. Dashboard live preview is local state -> iframe (no preview API endpoint).
8. Public embed fetches server-computed payload from widget public endpoint.
9. Both paths must use the same shared payload schema.
10. Draft editing is manual-save only (no autosave in v1).
11. Shadow DOM is the default isolation strategy; fallback to scoped classes only if critical issues arise.
12. CSS sanitizer uses `css-tree` with 5KB limit and whitelist validation.
## Delivery Phases
1. Phase A: Data model + validation + DB query layer.
2. Phase B: API services/routes (org + public payload).
3. Phase C: `@cio/widgets` package (plain Svelte runtime + loader contract).
4. Phase D: Dashboard widgets feature (list, editor, preview, publish).
5. Phase E: Cloudflare publish pipeline + observability + hardening.
## Ticket Breakdown
| ID | Area | Task | Key Files | Dependencies | Done When |
| --- | --- | --- | --- | --- | --- |
| CW-A1 | DB | Add widget schemas (`widget`, `widget_course`, `widget_version`) | `packages/db/src/schema.ts` | None | Tables/migrations compile with org-scoped FKs and indexes |
| CW-A2 | DB Queries | Implement widget query module | `packages/db/src/queries/widget/widget.ts` | CW-A1 | CRUD + version + selection queries implemented with error logging pattern |
| CW-A3 | Utils Validation | Add widget validation schemas | `packages/utils/src/validation/widget/widget.ts` + exports | CW-A1 | Zod schemas cover create/update/publish/public payload |
| CW-A4 | DB Types | Export new widget types | `packages/db/src/types.ts` | CW-A1 | Type generation/build includes widget entities |
| CW-B1 | API Service | Add widget service layer | `apps/api/src/services/widget.ts` | CW-A2,CW-A3 | Role/org checks + business rules in service layer |
| CW-B2 | API Routes | Add org widget routes | `apps/api/src/routes/organization/widgets.ts` | CW-B1 | CRUD/publish/rollback endpoints working |
| CW-B3 | API Routes | Add public widget payload route | `apps/api/src/routes/widgets/widgets.ts` | CW-B1 | Public payload returns publish-safe data only |
| CW-B4 | API Register | Register routes in app tree | `apps/api/src/routes/organization/index.ts`, `apps/api/src/app.ts` | CW-B2,CW-B3 | RPC + public routes reachable |
| CW-B5 | API Analytics | Track view/click events (optional gate) | `apps/api/src/services/widget.ts` + route | CW-B3 | Event path available or clearly deferred behind feature flag |
| CW-B6 | API Compute Service | Add canonical payload builder for public embed | `apps/api/src/services/widget-payload.ts`, `apps/api/src/routes/widgets/widgets.ts` | CW-B1 | Public payload route uses one compute function for courses + design config |
| CW-C1 | Widgets Package | Create `@cio/widgets` package (plain Svelte runtime) | `packages/widgets/*` | CW-A3 | Package builds to ESM/browser bundle without SvelteKit |
| CW-C2 | Layouts | Implement 5 layout renderers | `packages/widgets/src/layouts/*` | CW-C1 | All required layouts render from same config contract |
| CW-C3 | Design Tokens | Add config-to-style mapper + Shadow DOM isolation | `packages/widgets/src/styles/*` | CW-C1 | Host CSS does not break widget visuals; Shadow DOM encapsulation verified in tests |
| CW-C4 | Loader Contract | Add loader bootstrap interface | `packages/widgets/src/loader/*` | CW-C1 | Loader can mount widget by payload id/key |
| CW-C5 | Size Budget | Add bundle size CI checks (loader ≤3KB, runtime ≤35KB gzip) | `packages/widgets/package.json` scripts + CI workflow | CW-C1 | CI fails when runtime exceeds target budgets; budget file in `bundle-size.json` |
| CW-C6 | Renderer Contract | Add strict typed payload contract shared by dashboard and API | `packages/widgets/src/types/payload.ts` | CW-C1,CW-B6 | Renderer accepts canonical payload schema only |
| CW-D1 | Dashboard Types | Add request/response types for widget API | `apps/dashboard/src/lib/features/widget/utils/types.ts` | CW-B4 | Types follow `typeof classroomio...` pattern |
| CW-D2 | Dashboard API | Add widget API class | `apps/dashboard/src/lib/features/widget/api/widget.svelte.ts` | CW-D1 | All widget requests use `this.execute<RequestType>()` |
| CW-D3 | Widgets List Page | Add `/org/[slug]/widgets` list route/page | `apps/dashboard/src/routes/org/[slug]/widgets/+page.svelte`, `+page.server.ts` | CW-D2 | Admin can see/create/open widgets |
| CW-D4 | Editor Page | Add `/org/[slug]/widgets/[widgetId]` editor route/page | `apps/dashboard/src/routes/org/[slug]/widgets/[widgetId]/+page.svelte`, `+page.server.ts` | CW-D3,CW-D2 | Left panel tabs + live preview + save/publish works |
| CW-D5 | Sidebar Nav | Add widgets entry to org sidebar | `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` | CW-D3 | Sidebar highlights widgets route correctly |
| CW-D6 | Translations | Add all widget UI copy keys | `apps/dashboard/src/lib/utils/translations/en.json` | CW-D4 | No hardcoded user-facing strings |
| CW-D12 | Accessibility | Add ARIA labels, keyboard navigation, focus indicators | widget layouts + preview | CW-C2 | Widget passes axe-core automated checks |
| CW-D7 | Plan Gating UI | Add paid/free UI gate: 1 theme for free, all themes + custom colors for paid | widget feature pages + store | CW-D4 | Free users see 1 theme option with upgrade prompt; paid users see all themes + custom colors |
| CW-D8 | Live Preview Pipeline | Build local state -> iframe preview pipeline | `apps/dashboard/src/routes/org/[slug]/widgets/[widgetId]/+page.server.ts`, editor feature files, preview iframe files | CW-C6 | Editing left-panel settings re-renders iframe preview without API roundtrip |
| CW-D9 | Manual Save UX | Add explicit save/discard workflow and dirty-state handling | editor feature files + stores | CW-D8 | No autosave; unsaved changes are preserved locally until user saves/discards |
| CW-D10 | Iframe Protocol | Implement iframe message protocol with origin/schema validation per spec in README | preview parent + iframe runtime files | CW-D8 | `READY/RENDER/ERROR/RESIZE` flow works with 3 retry attempts and strict origin checks |
| CW-D11 | Freshness Controls | Add data freshness states + manual refresh button in editor | editor route/feature files | CW-D8 | Editor shows `Last synced`, supports manual refresh, and handles stale warnings |
| CW-E1 | Publish Pipeline | Build widget artifact + upload to Cloudflare | `scripts/widgets/*` or api worker hook | CW-C4,CW-B2 | Publish produces versioned URLs + manifest persisted |
| CW-E2 | Cloudflare Delivery | Configure edge/static delivery and cache policy | deployment config/docs | CW-E1 | Loader/assets served with immutable cache headers |
| CW-E3 | Hardening | Add CSS sanitizer (css-tree), key generation (base58), rate limiting | API service/routes | CW-B3 | Public endpoints pass security checks; CSS validator tests pass |
| CW-E4 | Tests | Unit/integration tests for config + publish + payload | API/db/package tests | CW-B4,CW-C2 | Core flows covered and passing in CI |
| CW-E5 | Rollout | Add feature flag + staged rollout checklist | env/config docs | CW-D4 | Safe enable/disable by environment |
## API Contract (Target)
### Org Protected
1. `GET /organization/widgets`
2. `POST /organization/widgets`
3. `GET /organization/widgets/:widgetId`
4. `PUT /organization/widgets/:widgetId`
5. `DELETE /organization/widgets/:widgetId`
6. `POST /organization/widgets/:widgetId/publish`
7. `POST /organization/widgets/:widgetId/rollback`
### Public
1. `GET /widgets/:publicKey/payload`
2. `POST /widgets/:publicKey/events` (if enabled in v1)
## Widget Runtime Notes (Strict)
1. Use plain Svelte components compiled via Vite library mode.
2. Do not ship SvelteKit runtime artifacts in embed bundle.
3. Keep dependency graph minimal (avoid heavy UI libraries in embed package).
4. Isolate styles (prefer Shadow DOM custom element or strict prefixed scoping).
5. Renderer input type is the canonical payload contract shared across dashboard preview and public embed.
## Dashboard UI Notes
1. Follow existing route pattern:
- route file contains `Page.Root/Page.Header`
- feature page contains business logic
2. Keep form controls using `@cio/ui` components.
3. Store all user-facing strings in translations.
4. Keep preview component using the same package renderer used in published bundle.
5. Editor `+page.server.ts` provides initial data for local preview state (courses/tags/draft config).
6. Left-panel changes update local payload and send it to iframe via `postMessage` (debounced/throttled).
7. Preview iframe should not fetch widget data; it only renders payload received from parent state.
8. No autosave; user must click `Save` to persist draft changes.
9. Provide explicit `Discard changes` action for local draft reset.
## Acceptance Criteria by Phase
### Phase A
1. Migration runs cleanly.
2. Org isolation enforced at query/service level.
3. Validation schemas compile in `@cio/utils`.
4. CSS sanitizer config defined and testable.
### Phase B
1. Admin can CRUD and publish widget via API.
2. Public payload endpoint returns only published widget versions.
3. Unauthorized org access returns proper 403/404 behavior.
4. Public payload endpoint uses canonical compute service for courses + design config.
5. CSS sanitization rejects malicious input in tests.
6. Rate limiting enforced on public endpoints.
### Phase C
1. `@cio/widgets` package builds independently.
2. Runtime meets bundle budget and renders all required layouts.
3. Host-site CSS does not break widget UI in smoke tests (Shadow DOM verified).
4. Renderer consumes only canonical computed payload type.
5. Accessibility: keyboard navigation and ARIA labels implemented.
6. Bundle size CI check fails builds exceeding budget.
### Phase D
1. Admin can create/edit/publish from `/org/[slug]/widgets`.
2. Tutors can edit drafts from `/org/[slug]/widgets` editor.
3. Editor left panel has `Select Courses`, `Widgets`, `Design`.
4. Live preview updates as settings change using local state passed to iframe.
5. Embed snippet is copyable from dashboard.
6. Plan gates visibly enforce free vs paid restrictions (disabled with tooltips).
7. Editing is manual-save only; no background persistence happens.
8. Iframe protocol is stable (`READY/RENDER/ERROR/RESIZE`) with strict origin validation and retry logic.
9. Data freshness UI supports manual refresh and stale-state warnings.
10. Accessibility: widgets pass axe-core automated checks.
### Phase E
1. Publish creates versioned immutable artifact URLs in Cloudflare R2.
2. Rollback switches manifest pointer to previous version without data loss.
3. Monitoring/logs show publish failures and public payload errors.
4. Artifact cleanup policy (10 versions kept) verified.
5. Cache headers correctly configured for all asset types.
## Verification Commands
1. `pnpm --filter @cio/utils build`
2. `pnpm --filter @cio/db build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/widgets build`
5. `pnpm --filter @cio/dashboard build`
6. Run widget API and package tests (unit/integration).
## Suggested First Sprint Slice
1. CW-A1 to CW-A4 (DB + validation foundation)
2. CW-B1 to CW-B6 (API services + routes + payload compute)
3. CW-C1 + CW-C6 (widgets package scaffold + payload contract)
4. CW-D3 + CW-D8 + CW-D9 (widgets list + preview pipeline + manual save skeleton)
This slice creates the core contracts and route surface so editor/publish can iterate quickly afterward.
### Definition of Ready per Ticket
Each ticket must include:
- Data flow diagram (if applicable)
- Interface/type definitions
- Error scenarios and handling
- Test plan (unit + integration)
### Definition of Done per Phase
- All builds pass (`pnpm --filter @cio/* build`)
- Bundle size check passes (Phase C+)
- Security review passed (Phase B+)
- Accessibility smoke test passed (Phase D+)
+343
View File
@@ -0,0 +1,343 @@
# Email Template Customization and Translation PRD
## Purpose
Enable organization admins to manage outbound email templates without code changes by editing:
- Email logo
- Email content
- Whether each email should be sent
This also introduces localization so email content is not hardcoded to English only.
## Problem Statement
Today, all templates in `packages/email/src/emails/*.ts` are:
- Hardcoded in English
- Hardcoded in code (no admin editing surface)
- Always sent when the related business event occurs
- Coupled to a static ClassroomIO logo in `getDefaultTemplate(...)`
This limits localization, white-labeling, and product control.
## Decision Summary
- v1 allows editing system-managed email template IDs only.
- Org admins cannot create new email event types or custom triggers from the UI.
- Engineering can add new system email IDs where legacy raw emails still exist.
- v1 supports per-template:
- `logo`
- `content`
- `isEnabled` (send toggle)
- v1 supports locale variants with fallback behavior.
## Current-State Audit
Legend: `available now`, `partial`, `missing`
| Capability | Availability | Current State | Effort | Should Implement |
| --- | --- | --- | --- | --- |
| Edit logo per template | missing | Static logo URL in default wrapper | Medium | [x] |
| Edit content per template | missing | HTML strings are hardcoded in TS files | Medium | [x] |
| Per-email send toggle | missing | Email sends are trigger-based only | Medium | [x] |
| Multi-language email content | missing | English-only template copy | Medium | [x] |
| Create new email types | missing | Requires code updates in registry/constants | High | [ ] (v1 non-goal) |
| Template preview before save | missing | No preview API/UI | Low | [x] |
| Audit trail for template changes | missing | No history | Medium | [x] |
## Existing Email IDs (Current Catalog)
From `packages/email/src/utils/constants.ts`:
- `forgotPassword`
- `welcome`
- `verifyEmail`
- `onPasswordReset`
- `inviteTeacher`
- `newsfeedPost`
- `newsfeedComment`
- `studentCourseWelcome`
- `studentCourseInvite`
- `teacherStudentJoined`
- `teacherStudentBuyRequest`
- `studentProvePayment`
- `teacherCourseWelcome`
## Goals
1. Organization admins can configure logo, content, and send toggle for each existing email ID.
2. Admins can manage localized content using supported locales (`en`, `hi`, `fr`, `pt`, `de`, `vi`, `ru`, `es`, `pl`, `da`).
3. Send pipeline resolves the best template by organization + locale with safe fallback.
4. Template resolution should not break existing `sendEmail(...)` call sites.
5. Keep type-safe Zod field validation already provided by `@cio/email`.
6. Migrate raw `deliverEmail(...)` product notifications into managed template IDs.
## Non-Goals (v1)
- Letting admins create brand new email types, custom event triggers, or campaign flows.
- Editing `subject`, `from`, or `replyTo` in dashboard.
- Drag-and-drop email builder.
- Advanced analytics (open rate, click rate).
- Attachments.
## Legacy Raw Emails to Standardize
Current product emails bypassing template registry:
- `apps/api/src/services/submission/submission.ts`:
- student submission status update email
- teacher notification on new student submission
Plan:
- Create system-managed IDs for these flows.
- Move content to template registry + editable overrides.
- Keep behavior compatible while migrating call sites.
## Functional Requirements
### Template Editing
Per existing email ID, admins can:
- Upload/select logo URL
- Edit localized HTML content
- Enable or disable sending
### Localization
- Template content is saved per locale.
- Fallback order at send time:
1. Org + emailId + requested locale
2. Org + emailId + `en`
3. Code default template renderer (current hardcoded fallback)
### Send Toggle
- If template for an email ID is disabled, send is skipped and logged.
- Skip must not fail business actions (invites, onboarding, etc.).
- `required` email IDs can be protected from disabling (recommendation below).
### Placeholder Safety
- Content can use variable placeholders from each template schema (for example `{{inviteLink}}`, `{{studentName}}`).
- Save validation rejects unknown placeholders for the selected email ID.
- Render-time validation continues using existing Zod schema.
## Product Rules (Recommended)
1. Email IDs classified as:
- `required`: `forgotPassword`, `verifyEmail`, `onPasswordReset`
- `optional`: all remaining IDs
2. `required` IDs cannot be disabled in UI/API.
3. Missing localized content shows explicit fallback badge (`Using English fallback`).
## Proposed Architecture
### Data Model (New)
#### `organization_email_template`
Organization-scoped override rows for existing email IDs.
Suggested fields:
- `id` (uuid, pk)
- `organization_id` (uuid, fk -> `organization.id`, indexed)
- `email_id` (text, constrained to known email IDs)
- `locale` (`LOCALE`, default `en`)
- `is_enabled` (boolean, default `true`)
- `logo_url` (text, nullable)
- `content` (text, nullable; null means fallback to code default content)
- `updated_by_profile_id` (uuid, fk -> `profile.id`)
- `created_at`, `updated_at`
Constraints:
- unique `(organization_id, email_id, locale)`
- index `(organization_id, email_id)`
- index `(organization_id, locale)`
Notes:
- Row presence means override intent.
- `content = null` allows logo/toggle-only overrides.
#### `organization_email_template_audit`
Append-only audit trail of changes.
Suggested fields:
- `id` (uuid, pk)
- `organization_id` (uuid, indexed)
- `email_template_id` (uuid, fk -> `organization_email_template.id`)
- `action` (`CREATED` | `UPDATED` | `DELETED` | `ENABLED` | `DISABLED`)
- `before` (jsonb)
- `after` (jsonb)
- `actor_profile_id` (uuid)
- `created_at`
## Backend Plan
### Validation (`packages/utils/src/validation/email-template/`)
- `ZListEmailTemplatesQuery`
- `ZGetEmailTemplateParams`
- `ZUpsertEmailTemplate`
- `ZDeleteEmailTemplate`
- `ZPreviewEmailTemplate`
`ZUpsertEmailTemplate` (v1):
- `emailId: EmailId`
- `locale: TLocale`
- `isEnabled?: boolean`
- `logoUrl?: string | null`
- `content?: string | null`
### Queries (`packages/db/src/queries/email-template/`)
- `listOrganizationEmailTemplates`
- `getOrganizationEmailTemplate`
- `upsertOrganizationEmailTemplate`
- `deleteOrganizationEmailTemplate`
- `createOrganizationEmailTemplateAudit`
- `getResolvedOrganizationEmailTemplate` (with fallback)
### Services (`apps/api/src/services/email-template/`)
- `listEmailTemplatesService`
- `getEmailTemplateService`
- `upsertEmailTemplateService`
- `deleteEmailTemplateService`
- `previewEmailTemplateService`
- `resolveEmailTemplateForSendService`
### Routes (`apps/api/src/routes/email-template/`)
Mounted under organization context:
- `GET /organization/email-templates`
- `GET /organization/email-templates/:emailId`
- `PUT /organization/email-templates/:emailId`
- `DELETE /organization/email-templates/:emailId`
- `POST /organization/email-templates/:emailId/preview`
All routes: `authMiddleware` + `orgAdminMiddleware`.
## Send Pipeline Integration
### Current
`sendEmail(emailId, { to, fields, ... })` renders from registered code template directly.
### Proposed
Add optional send context:
- `organizationId?: string`
- `locale?: TLocale`
Flow:
1. Existing template schema validation remains unchanged.
2. If `organizationId` exists, resolve override with locale fallback.
3. Determine final `isEnabled`.
4. If disabled, return skipped result `{ success: true, skipped: true, reason: 'TEMPLATE_DISABLED' }`.
5. Render final email wrapper with resolved `logoUrl` and `content` (or default fallback).
Implementation note:
- Keep backward compatibility by making new context optional.
- Existing call sites without org context continue to use default code templates.
## Frontend Plan (Dashboard)
### Route
- `apps/dashboard/src/routes/org/[slug]/settings/emails/+page.svelte`
### Navigation
Add settings tab item under org settings:
- title: `Emails`
- path: `/settings/emails`
### Feature Module
`apps/dashboard/src/lib/features/email-template/`
- `api/email-template.svelte.ts`
- `utils/types.ts`
- `utils/email-template-utils.ts`
- `components/email-template-list.svelte`
- `components/email-template-editor.svelte`
- `components/email-template-preview.svelte`
- `pages/email-template-settings.svelte`
### UX (v1)
- Left panel: list of existing email IDs + enabled status.
- Top controls: locale selector + fallback indicator.
- Editor:
- logo upload/URL
- content editor (HTML)
- placeholder helper for selected email ID
- enable/disable switch
- Preview pane renders current locale result.
- Save + Reset actions.
### Copy and Translations
- No hardcoded UI strings.
- Add keys in `apps/dashboard/src/lib/utils/translations/en.json` and other locales as needed.
## Phased Rollout
### Phase 0: Foundation
1. DB tables + migration.
2. Validation + queries + services + routes.
3. Add metadata catalog for email IDs and allowed placeholders.
4. Add system-managed IDs for legacy raw submission emails.
### Phase 1: Dashboard Management
1. Add org settings email page.
2. List, edit, save, reset template overrides.
3. Preview endpoint + preview UI.
### Phase 2: Send Pipeline Adoption
1. Add optional `organizationId`/`locale` context in send flow.
2. Update org-scoped call sites to pass context.
3. Add skip logging for disabled templates.
### Phase 3: Hardening
1. Audit logs UI/read endpoint.
2. Placeholder validation improvements and warnings.
3. Performance optimizations and caching.
## Migration Plan
No hard data migration is required for v1 because defaults already exist in code.
Rollout strategy:
1. Ship schema and API with no behavioral change.
2. Ship UI and allow override creation.
3. Enable send-time override resolution.
4. Incrementally update send call sites to pass org/locale context.
## Risks and Mitigations
- Risk: Admin enters broken HTML.
- Mitigation: sanitize on save + render preview with warnings.
- Risk: Required variables removed from content.
- Mitigation: placeholder validation against schema keys.
- Risk: Disabling critical auth emails blocks account flows.
- Mitigation: `required` email IDs are non-disableable.
- Risk: Missing org context in some send call sites.
- Mitigation: safe fallback to existing code templates until upgraded.
- Risk: Large refactor footprint across services.
- Mitigation: optional send context and gradual adoption by domain.
## Success Metrics
- At least 80% of active organizations with non-default email templates in first 60 days.
- At least 90% of org-scoped email sends resolved through template override service.
- Zero critical auth incidents caused by template disable misconfiguration.
- Reduction in support requests about email branding/language by at least 50%.
## Open Questions
1. Should `welcome` email be org-customizable or remain global/system-owned?
2. Do we allow plain-text editor mode in addition to HTML mode?
3. Should logo override be per-template or org-global default with optional per-template override?
4. Should locale resolution use recipient profile locale automatically when context locale is omitted?
5. Do we need test-send endpoint in v1 or can preview-only ship first?
## Kanban Board (Ideation Phase)
| Ticket | Title | Todo | In Progress | Verification | Done |
| --- | --- | --- | --- | --- | --- |
| ET-1 | DB: add `organization_email_template` table | [ ] | [ ] | [ ] | [ ] |
| ET-2 | DB: add `organization_email_template_audit` table | [ ] | [ ] | [ ] | [ ] |
| ET-3 | Validation schemas for email-template APIs | [ ] | [ ] | [ ] | [ ] |
| ET-4 | Queries for template CRUD + resolve fallback | [ ] | [ ] | [ ] | [ ] |
| ET-5 | Service layer for list/get/upsert/delete/preview | [ ] | [ ] | [ ] | [ ] |
| ET-6 | Organization routes `/email-templates` | [ ] | [ ] | [ ] | [ ] |
| ET-7 | Dashboard settings page for templates | [ ] | [ ] | [ ] | [ ] |
| ET-8 | Locale-aware editor and fallback indicators | [ ] | [ ] | [ ] | [ ] |
| ET-9 | Preview rendering endpoint + UI pane | [ ] | [ ] | [ ] | [ ] |
| ET-10 | Send pipeline context and override resolution | [ ] | [ ] | [ ] | [ ] |
| ET-11 | Update org-scoped send call sites | [ ] | [ ] | [ ] | [ ] |
| ET-12 | Required-email toggle guardrails | [ ] | [ ] | [ ] | [ ] |
| ET-13 | Audit log write/read integration | [ ] | [ ] | [ ] | [ ] |
| ET-14 | Caching/performance pass | [ ] | [ ] | [ ] | [ ] |
| ET-15 | Migrate raw submission emails to managed template IDs | [ ] | [ ] | [ ] | [ ] |
## Verification Checklist
- [ ] `pnpm --filter @cio/db build`
- [ ] `pnpm --filter @cio/utils build`
- [ ] `pnpm --filter @cio/email build`
- [ ] `pnpm --filter @cio/api build`
- [ ] `pnpm --filter @cio/dashboard build`
- [ ] Manual: edit template logo + save + preview
- [ ] Manual: edit localized content + locale fallback behavior
- [ ] Manual: disable optional email and verify skip behavior
- [ ] Manual: ensure required emails cannot be disabled
- [ ] Manual: send flow still works for routes not yet migrated to org context
+424
View File
@@ -0,0 +1,424 @@
# Enterprise SSO PRD (SAML 2.0 + OIDC)
## Purpose
Add enterprise SSO for ClassroomIO with per-organization configuration, supporting both SAML 2.0 and OIDC, while preserving the current Better Auth session model and multi-tenant org permissions.
## Confirmed Decisions (2026-02-17)
1. IdPs to support: Auth0, Google Workspace, Okta.
2. Scope: per organization (not global tenant-wide config).
3. Protocols: OIDC for v1. SAML 2.0 deferred to v2.
4. Force SSO: enabled (orgs can disable email/password when SSO is enabled).
5. Provisioning: JIT only for v1. SCIM deferred to future phase.
6. SAML deferred to v2 (Better Auth SAML is in active development).
7. Break-glass: simplified emergency access via pre-configured admin bypass.
8. Domain enforcement: simplified to auto-join toggle.
9. Entitlement: SSO is gated to `PLAN.ENTERPRISE`.
10. SCIM deprovisioning: future phase, with interim login-time entitlement checks.
## Architecture: Better Auth SSO Plugin
v1 leverages **Better Auth's official SSO plugin** (`@better-auth/sso`) for OIDC support:
- Built-in OIDC discovery and flow handling
- Automatic callback URL generation
- Domain-based provider routing
- Claim mapping support
- Session management via Better Auth
Our custom layer handles:
- Enterprise plan entitlement checks
- Organization-scoped provider management
- Force SSO policy enforcement
- Org membership JIT provisioning
- Admin configuration UI
## Current-State Audit
| Capability | Current State | Notes |
| --- | --- | --- |
| Auth framework | available | Better Auth with cookie sessions (`packages/db/src/auth.ts`) |
| Email/password auth | available | Enabled via `emailAndPassword` (`packages/db/src/auth/email-password.ts`) |
| Social login | partial | Google social login only (`packages/db/src/auth.ts`, `apps/dashboard/src/lib/features/ui/auth-ui.svelte`) |
| Session handling | available | `/api/auth/*` mounted in API, `auth.api.getSession` middleware (`apps/api/src/app.ts`) |
| Multi-tenant org model | available | `organization`, `organizationmember`, org role checks and `cio-org-id` header enforcement |
| Org invite flows | available | Role-aware organization invites already implemented |
| Enterprise SSO (SAML/OIDC per org) | missing | No SSO connection model, no discovery, no SAML/OIDC enterprise callback flows |
| Force SSO policy | missing | No org-level policy to block local email/password |
| SCIM | missing | No SCIM endpoints or provisioning sync |
## Product Goals
1. Allow each organization to configure one SSO connection.
2. Support Auth0, Google Workspace, and Okta with OIDC.
3. Preserve existing Better Auth sessions/cookies and downstream auth middleware behavior.
4. Add Force SSO policy at org level.
5. Keep invite/onboarding flows compatible with SSO.
6. Ship JIT provisioning for v1.
7. Add simple domain-based auto-join policy.
8. Gate all SSO capabilities to Enterprise plan organizations.
## Non-Goals (v1)
- **SAML 2.0** (deferred to v2 - Better Auth SAML is in active development).
- SCIM implementation (deferred to future phase).
- Multiple SSO connections per organization.
- Custom claim mapping UI (use standard claims).
- Global logout / SAML Single Logout (SLO).
- Arbitrary custom IdP templates beyond Auth0/Google Workspace/Okta.
- Cross-organization identity linking rules beyond current email + org membership model.
## Data Sources Checked
- `packages/db/src/auth.ts`
- `packages/db/src/auth/email-password.ts`
- `apps/api/src/app.ts`
- `apps/api/src/middlewares/auth.ts`
- `apps/api/src/middlewares/org-member.ts`
- `apps/api/src/middlewares/org-admin.ts`
- `packages/db/src/schema.ts`
- `packages/db/src/queries/organization/organization.ts`
- `apps/api/src/services/account.ts`
- `apps/api/src/services/organization/invite.ts`
- `apps/api/src/services/course/invite.ts`
- `apps/dashboard/src/lib/utils/services/auth/client.ts`
- `apps/dashboard/src/lib/utils/services/auth/session.ts`
- `apps/dashboard/src/routes/(auth)/login/+page.svelte`
- `apps/dashboard/src/lib/features/ui/auth-ui.svelte`
- `apps/dashboard/src/lib/features/app/init.svelte.ts`
- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`
- `apps/docs/content/docs/quickstart/self-hosting.mdx`
- `apps/docs/content/docs/meta.json`
- `apps/docs/content/docs/quickstart/meta.json`
## Functional Requirements
### 1. Per-Organization SSO Connection Management
- Org admins can create/update/disable one SSO connection per org.
- Supported providers: Auth0, Google Workspace, Okta.
- Supported protocols: OIDC and SAML 2.0.
- Connection supports test/verify before activation.
### 2. SSO Discovery and Login Start
- On org domains (`{org}.domain.com` or custom domain), login should use that org context directly.
- On shared app domain, user can enter email to discover SSO route by org policy/domain mapping.
- Discovery response should indicate:
- SSO required or optional
- Provider type
- Whether password login is allowed
### 3. OIDC Flow (Better Auth SSO Plugin)
Better Auth handles the OIDC flow:
- Authorization code flow with PKCE, `state`, and `nonce`.
- Auto-discovery from `.well-known/openid-configuration`.
- Token validation and userinfo retrieval.
- Claim mapping to user fields.
Our layer handles:
- Provider registration via `auth.api.registerSSOProvider`.
- Post-sign-in org membership provisioning via `dbHooks`.
- Role mapping from `groups` claim.
### 4. SAML 2.0 Flow (v2)
- **Deferred to v2** - will be implemented once Better Auth's SAML support is stable.
### 5. JIT Provisioning and Membership Linking
- On successful SSO callback:
- Find or create user/profile
- Upsert org membership in `organizationmember`
- Link external identity (`connection + subject`) to local user
- If invited member row exists by email, bind `profileId` and mark verified
- Default JIT role for new users: `ROLE.STUDENT`
### 6. Role Mapping
- Simple role mapping from IdP `groups` claim:
- Map group names to `ROLE.ADMIN`, `ROLE.TUTOR`, `ROLE.STUDENT`
- If no mapping matched, fallback to `ROLE.STUDENT`
- Configuration: simple key-value mapping in connection settings
### 7. Force SSO
- Org-level `forceSso=true` disables email/password login for that org context
- Enforce server-side, not only UI-level
- Users attempting password login for a force-SSO org receive structured error + SSO redirect hints
- Existing email/password sessions: remain valid until expiry (no forced logout)
- Break-glass: pre-configured emergency admin can bypass (audited)
### 8. Audit and Observability
- Use existing audit/logging infrastructure with structured event types:
- `sso.connection_created`, `sso.connection_updated`, `sso.connection_disabled`
- `sso.login_success`, `sso.login_failure`
- `sso.provisioned`, `sso.role_assigned`
- `sso.force_sso_block`
- Include orgId, connectionId, actor/profile (when available), IP, user agent
### 9. Domain Auto-Join Policy
- Simple toggle: `auto_join_sso_domains` (boolean)
- When enabled, users with matching email domain can auto-join org via SSO
- When disabled, users must have pending invite to join via SSO
- Trust IdP-verified email domains
### 10. Enterprise Plan Entitlement
- SSO configuration and login endpoints must verify org entitlement to `PLAN.ENTERPRISE`
- Non-enterprise orgs receive explicit upgrade-required responses
## Technical Design
### Architecture Summary
Leverage **Better Auth SSO plugin** (`@better-auth/sso`) for OIDC protocol handling. Our custom layer provides:
1. **Admin management** - CRUD SSO connections (stored in our schema for UI)
2. **Enterprise gating** - Verify `PLAN.ENTERPRISE` before allowing SSO
3. **Force SSO enforcement** - Block email/password when policy enabled
4. **JIT provisioning** - Create org membership post-authentication via `dbHooks`
Better Auth handles:
- OIDC discovery, authorization, token exchange
- Session creation and management
- User creation/linking
### Data Model
#### `organization_auth_policy`
- `organization_id` (uuid, pk/fk)
- `force_sso` (boolean, default `false`)
- `default_role_id` (bigint, default `ROLE.STUDENT`)
- `allow_email_password_fallback` (boolean, default `true`)
- `auto_join_sso_domains` (boolean, default `false`)
- `break_glass_enabled` (boolean, default `true`)
- `created_at`, `updated_at`
#### `organization_sso_connection`
- `id` (uuid, pk)
- `organization_id` (uuid, fk, indexed, unique)
- `provider` (`OKTA` | `GOOGLE_WORKSPACE` | `AUTH0`)
- `protocol` (`OIDC`) - enum for future SAML extension
- `display_name` (text)
- `is_active` (boolean, default `false`)
- `issuer` (text) - IdP issuer URL
- `domain` (text) - email domain for discovery
- `oidc_config` (jsonb) - clientId, scopes, etc (clientSecret stored in Better Auth)
- `role_mapping` (jsonb) - simple key-value: group name → role
- `better_auth_provider_id` (text) - reference to Better Auth's stored provider
- `created_by_profile_id`, `updated_by_profile_id` (uuid)
- `created_at`, `updated_at`
#### `sso_identity_link`
- `id` (uuid, pk)
- `organization_id` (uuid, indexed)
- `connection_id` (uuid, fk)
- `user_id` (uuid, fk to `user.id`)
- `external_subject` (text)
- `email_at_link` (text)
- `last_login_at` (timestamp)
- `created_at`, `updated_at`
Constraints:
- unique `(connection_id, external_subject)`
- unique `(organization_id, user_id, connection_id)`
## API and Route Plan
### Better Auth Integration
The SSO plugin provides these endpoints automatically:
- `POST /api/auth/sso/register` - Register OIDC provider (admin only)
- `GET/POST /api/auth/sso/authorize/:providerId` - Start SSO flow
- `GET/POST /api/auth/sso/callback/:providerId` - OIDC callback
### Our Custom Layer
#### Validation Layer
- `packages/utils/src/validation/organization/sso.ts`
- `ZCreateSsoConnection`
- `ZUpdateSsoConnection`
- `ZSsoDiscoveryQuery`
#### Query Layer
- `packages/db/src/queries/organization/sso.ts`
- connection CRUD
- org auth policy read/write
#### Service Layer
- `apps/api/src/services/organization/sso.ts`
- Admin CRUD with Enterprise plan check
- Call `auth.api.registerSSOProvider` on activation
- Call `auth.api.deleteSSOProvider` on deactivation
- `apps/api/src/services/auth/sso.ts`
- Discovery logic
- Force SSO enforcement
#### Route Layer
- `apps/api/src/routes/organization/sso.ts` - Admin CRUD endpoints
- `apps/api/src/routes/sso/discovery.ts` - Public discovery endpoint
- Register in:
- `apps/api/src/routes/organization/index.ts`
- `apps/api/src/app.ts`
#### Better Auth Hooks
- `dbHooks.user.afterCreate` - JIT org membership provisioning
## Frontend Plan (Dashboard)
### Login UX
- Update:
- `apps/dashboard/src/routes/(auth)/login/+page.svelte`
- `apps/dashboard/src/lib/features/ui/auth-ui.svelte`
- Add SSO discovery step:
- org-site context: direct SSO action for current org
- shared app login: email-based discovery before method selection
- If Force SSO for org, hide password fields and show SSO-only CTA
### Org Settings UX
- Add new page: `apps/dashboard/src/routes/org/[slug]/settings/sso/+page.svelte`
- Add settings feature page: `apps/dashboard/src/lib/features/settings/pages/sso.svelte`
- Export page in `apps/dashboard/src/lib/features/settings/pages/index.ts`
- Add org navigation nested route in `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`
- Add controls for:
- Provider selection (Auth0, Google Workspace, Okta)
- Protocol selection (OIDC, SAML)
- Force SSO toggle
- Auto-join toggle
- Simple role mapping
- Break-glass enable/disable
- Add translation keys in `apps/dashboard/src/lib/utils/translations/en.json`
### Admin SSO Configuration UI Spec
#### Page Information Architecture
- `Connection` section:
- Current SSO connection status (provider, protocol, active/inactive)
- Actions: `Configure`, `Edit`, `Disable`, `Test`, `Activate`
- `Policies` section:
- Force SSO toggle
- Auto-join toggle
- Break-glass toggle
- `Role Mapping` section:
- Simple table: IdP group name → ClassroomIO role
#### Setup Wizard (Create/Edit Connection)
- Step 1 `Choose Provider`:
- Provider: `Auth0`, `Google Workspace`, `Okta`
- Protocol: `OIDC` or `SAML`
- Step 2 `Configure Identity Provider`:
- Render provider/protocol-specific fields
- Show generated callback values to copy into IdP
- Step 3 `Test Connection` (optional):
- Run live test with state/nonce checks
- Display pass/fail results with remediation hints
- Step 4 `Activate`:
- Summary of config
- Activate now or save as draft
- If Force SSO enabled, require explicit confirmation
#### Provider-Specific Form Fields (OIDC Only)
- Common fields:
- `display_name` (required)
- `domain` (required) - email domain for discovery (e.g., `company.com`)
- OIDC fields:
- `issuer` (required URL) - e.g., `https://company.okta.com`
- `client_id` (required)
- `client_secret` (required) - sent to Better Auth, not stored locally
- `scopes` (default `openid profile email`)
- Read-only: callback URL (`/api/auth/sso/callback/{providerId}`)
#### Validation Rules and Error UX
- Field-level validation:
- URLs must be valid HTTPS (localhost exception for dev)
- Required fields present before activation
- Claim mapping must include `email` and `sub`/`NameID`
- Cross-field validation:
- `force_sso=true` requires active connection
- Connection can be saved as draft without full validation
- Error presentation:
- Inline field errors for input validation
- Structured backend errors for: issuer mismatch, invalid cert, callback mismatch, missing claims
#### Safety and Recovery UX
- Draft mode supported; drafts cannot be used for login
- Disable/rollback action always available to org admins
- All admin actions write visible audit entries
### Client Types/API Class
- Add request types in `apps/dashboard/src/lib/features/org/utils/types.ts`
- Add API class in `apps/dashboard/src/lib/features/org/api/sso.svelte.ts`
- Use `this.execute<RequestType>()` pattern only
## Documentation Plan (`apps/docs`)
### Required Documentation Updates
- Add core feature documentation:
- `apps/docs/content/docs/enterprise-sso.mdx` - feature overview
- Add how-to guides:
- `apps/docs/content/docs/how-to/sso-auth0.mdx`
- `apps/docs/content/docs/how-to/sso-google-workspace.mdx`
- `apps/docs/content/docs/how-to/sso-okta.mdx`
- Register pages in root docs nav:
- `apps/docs/content/docs/meta.json` under `---Core Features---`
- Update self-hosting reference:
- `apps/docs/content/docs/quickstart/self-hosting.mdx`
- Include SSO-related env vars and callback URL guidance
### Required Topics in Docs
- SSO prerequisites and Enterprise plan requirement
- Provider-by-provider setup checklist (Auth0, Google Workspace, Okta)
- Force SSO behavior and user experience
- JIT behavior and invite interactions
- Break-glass policy
- Troubleshooting: callback mismatch, certificate issues, entitlement errors
## Security Requirements
1. Encrypt all SSO secrets at rest
2. Strict signature/issuer/audience checks for SAML/OIDC
3. Anti-CSRF and replay protection via `state`/`nonce` and one-time callback state
4. Rate-limit discovery/start/callback endpoints
5. Full audit trail for admin config and auth outcomes via existing logging
6. No trust of frontend-enforced Force SSO alone; must be validated on backend
## Rollout Plan
### Phase 1: Foundation + OIDC + JIT
- Install `@better-auth/sso` plugin
- Schema + policy/connection management
- Integration with Better Auth SSO plugin
- OIDC flows for Auth0/Google Workspace/Okta via Better Auth
- SSO discovery + login UI
- Force SSO enforcement
- Domain auto-join policy + admin settings UI
- Enterprise plan entitlement checks
- JIT org membership via `dbHooks`
- Publish docs updates
### Phase 2: SAML 2.0
- Enable when Better Auth SAML is stable
- SAML flows for supported providers
- Force SSO parity for SAML
### Phase 3: Future Enhancements
- SCIM provisioning (if customer demand)
- Multiple connections per org (if customer demand)
- Advanced claim mapping (if customer demand)
## Acceptance Criteria
1. Org admin can configure an active SSO connection (OIDC).
2. User can successfully login through Auth0, Google Workspace, and Okta.
3. Successful SSO login creates/links user and org membership via JIT.
4. Force SSO org cannot authenticate through email/password for org-context access.
5. Auto-join policy is configurable in UI and enforced server-side.
6. SSO access is blocked for non-enterprise orgs.
7. Existing non-SSO orgs keep current auth behavior unchanged.
8. Audit events are logged for config changes and SSO auth events.
9. `apps/docs` includes SSO feature docs and provider how-to guides before GA.
10. Org admins can configure Auth0, Google Workspace, and Okta through a setup wizard.
11. Connection can be saved as draft without full validation.
12. UI surfaces actionable errors for provider setup and configuration issues.
## Risks and Mitigations
- Risk: Force SSO lockout due to bad config.
- Mitigation: connection can be disabled by any org admin + break-glass emergency access.
- Risk: role escalation by incorrect claim mapping.
- Mitigation: simple mapping validation + default least privilege (`ROLE.STUDENT`).
- Risk: callback replay attacks.
- Mitigation: one-time state, timestamp windows, assertion ID tracking.
## Finalized Decisions
1. **OIDC only for v1** - SAML deferred to v2 (Better Auth SAML in active development).
2. Use **Better Auth SSO plugin** (`@better-auth/sso`) for OIDC flow handling.
3. Break-glass local admin access is included with strict auditing.
4. Domain enforcement simplified to auto-join toggle.
5. SSO is gated to `PLAN.ENTERPRISE`.
6. SCIM deferred to future phase.
7. Single connection per org for v1.
8. Standard claims only, no custom claim mapping UI.
@@ -0,0 +1,434 @@
# Exercise Question Types Expansion PRD
## Status
- In progress
## Date
- February 24, 2026
## Implementation Plan
- See [implementation-plan.md](./implementation-plan.md) for ticket breakdown and delivery sequencing.
## Purpose
Expand ClassroomIO exercise questions beyond `single answer`, `multiple answers`, and `paragraph`, so instructors can run stronger assessments, reduce manual grading where possible, and collect richer assignment submissions inside the LMS.
## Problem Statement
Current exercise functionality is constrained by three legacy question types and a grading/answer model tuned for those types. This creates four product issues:
1. Limited assessment design for instructors.
2. Lower auto-grading coverage.
3. Weak first-class support for assignment-style submissions.
4. Increased instructor grading overhead and external-tool leakage.
## Current-State Audit
| Area | Current State | Gaps |
| --- | --- | --- |
| Question types | `RADIO`, `CHECKBOX`, `TEXTAREA` only | No true/false, numeric, fill-blank, matching, ordering, file-upload, hotspot |
| DB `question_type` | Seeded from `packages/db/src/utils/seed/questionType.ts` | Typename coverage too narrow |
| Validation constants | `QUESTION_TYPE` in `packages/utils/src/validation/constants.ts` contains only 3 numeric IDs | Hardcoded numeric assumptions are fragile as type set grows |
| Exercise validation | `packages/utils/src/validation/exercise/exercise.ts` has option rules for radio/checkbox + textarea | No schema-per-type authoring or submission validation |
| Exercise authoring UI | `edit-mode.svelte` renders only radio, checkbox, textarea | No renderer registry or extensibility contract |
| Exercise taking UI | `view-mode.svelte` answer flow assumes options or open text | No type-specific interaction patterns for new formats |
| Submission model | `question_answer` stores `answers[]` + `open_answer` | No structured payload for richer responses (fill blanks, ordering, matching, uploads) |
| Attempt controls | No max-attempt rule or attempt scoring strategy on `exercise` | Cannot configure highest/latest/average grading behavior |
| Test controls | No persisted timer/randomization/availability settings on exercise | Missing in-schema support for test-mode controls |
| File response support | No exercise-question-level upload config | Cannot do per-question file constraints |
## Data Sources Checked
- `packages/db/src/schema.ts`
- `packages/db/src/utils/seed/questionType.ts`
- `packages/db/src/queries/exercise/exercise.ts`
- `packages/utils/src/validation/constants.ts`
- `packages/utils/src/validation/exercise/exercise.ts`
- `apps/api/src/routes/course/exercise.ts`
- `apps/api/src/services/exercise/exercise.ts`
- `apps/api/src/services/exercise/utils.ts`
- `apps/api/src/services/submission/submission.ts`
- `apps/api/src/services/course/clone.ts`
- `apps/dashboard/src/lib/features/ui/question/constants.ts`
- `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`
- `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte`
- `apps/dashboard/src/lib/features/course/components/exercise/functions.ts`
- `apps/dashboard/src/lib/features/course/components/exercise/store.ts`
- `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts`
## Primary Users
- Instructors creating tests and assignments.
- Students answering exercises on desktop and mobile.
- Admins and tutors grading and reviewing progress.
## Goals
1. Support additional objective and subjective question types.
2. Increase auto-grading coverage for objective formats.
3. Add first-class assignment upload support at question level.
4. Keep authoring UX consistent across question types.
5. Add explicit exercise-level controls for attempts, scoring rule, answer reveal, and test behavior.
## Non-Goals (v1)
- Online proctoring.
- AI-generated grading feedback.
- Code execution/judging sandboxes.
- LTI/QTI export.
- Negative marking.
- Audio/video response question type.
## Confirmed Decisions
1. Code execution is out of scope.
2. Upload size limits are plan-based: Free `2MB`, Paid `10MB`.
3. Resubmission policy is exercise-level, not per question.
4. Rollout phases remain `P0`, `P1`, `P2`.
5. If an exercise contains `short answer` or `paragraph`, final grading is manual.
6. Numeric-answer grading mode is configurable.
7. Fill-in-the-blank does not support multiple accepted answers per blank in v1.
8. Partial credit is enabled by default where supported.
9. Partial credit settings are question-level with exercise-level override.
10. File upload configuration is per question.
11. Exercise settings include max attempts.
12. Correct-answer visibility is exercise-level configurable.
13. Test-only controls are in scope.
14. Allowed upload file types in v1: `PDF`, `DOCX`, `PPTX`, `XLSX`, `ZIP`, and images.
15. Attempt grading rule is exercise-level configurable: `highest`, `latest`, `average`.
16. Upload question accepts exactly one file in v1.
## Proposed Question Types
| Type | Auto-gradable | Partial Credit | Assignment Fit | Test Fit | Phase |
| --- | --- | --- | --- | --- | --- |
| True/False | Yes | No | Medium | High | P0 |
| Short Answer (single-line) | No | N/A | High | High | P0 |
| Numeric Answer | Yes | Optional | Medium | High | P0 |
| Fill in the Blank | Yes | Yes | Medium | High | P0 |
| File Upload | No | N/A | High | Low | P0 |
| Matching | Yes | Yes | Medium | High | P1 |
| Ordering/Sequence | Yes | Yes | Medium | High | P1 |
| Hotspot/Image Labeling | Yes | Optional | Medium | Medium | P2 |
## Functional Requirements
### Authoring
- Instructor can choose question type from a type picker.
- Instructor can configure:
- prompt
- points
- required/optional
- per-question instructions
- type-specific settings
- Instructor can define answer key for auto-graded types.
- Instructor can configure scoring mode (`all_or_nothing` or `partial`) where supported.
- Instructor can preview question behavior before publish.
### Student Experience
- Students can answer all released types on desktop and mobile.
- Answers autosave while taking exercise.
- One exercise can contain mixed question types.
- File upload question enforces allowed MIME/extensions and plan-based limits.
- File upload question accepts one file in v1.
### Grading
- Objective types auto-grade immediately unless exercise is in manual-only mode.
- Manual types route to grading queue with teacher feedback.
- Instructors can override auto-graded scores.
- Gradebook stores per-question points and final exercise score.
- Exercise-level `auto-grade` toggle can only be enabled when all active question types are auto-gradable; otherwise it must be disabled with a hint message.
### Attempts and Test Controls
- Exercise-level settings:
- `maxAttempts`
- `attemptScoringRule` (`highest`, `latest`, `average`)
- `showCorrectAnswers` policy
- `timerSeconds` (optional)
- `randomizeQuestionOrder`
- `randomizeOptionOrder`
- availability window (`availableFrom`, `availableTo`)
## Technical Design
### 1. Canonical Question Type Registry
Keep one canonical source for question type metadata used by API and dashboard.
Target package:
- `packages/question-types/src/` (new, published as `@cio/question-types`)
Registry includes:
- stable key (`TRUE_FALSE`, `SHORT_ANSWER`, etc.)
- DB typename
- DB id (resolved at startup/seed, not hardcoded in scattered files)
- auto-gradable flag
- partial-credit support
- submission payload schema key
- rollout phase
### 1.1 Canonical Renderer Components
Keep question renderer components in `@cio/ui` and keep all shared logic/contracts in `@cio/question-types`.
Target package:
- `packages/ui/src/custom/exercise-question/` (new)
- exports via `packages/ui/src/index.ts`
Rules:
- renderer registry/contracts live in `@cio/question-types`.
- answer serialization and shared form-state utilities live in `@cio/question-types`.
- render contract object lives in `@cio/question-types` (`ExerciseQuestionRenderContract` / `ExerciseQuestionListRenderContract`) and is deterministic for the same inputs.
- question type renderer components live in `@cio/ui/custom/exercise-question`.
- `@cio/ui/custom/exercise-question` is components-only and does not own shared exercise logic.
- dashboard screens consume type keys/contracts from `@cio/question-types` and renderers from `@cio/ui/custom/exercise-question`.
### 1.2 Cross-App File Contract
- The complete individual file list for reusable shared packages is defined in `implementation-plan.md` under `Shared Package File Contract (Cross-App Reuse)`.
- Shared package code must stay app-agnostic so any app can consume it to build an exercise/forms product.
### 2. Data Model Changes
#### `question` table
Add:
- `settings` (`jsonb`, default `{}`, not null)
- `scoring_mode` (`varchar`, default `all_or_nothing`)
- `manual_grading_required` (`boolean`, default `false`)
`settings` examples:
- numeric: `{ "mode": "exact" | "tolerance_abs" | "tolerance_pct", "tolerance": number }`
- upload: `{ "maxFiles": 1, "allowedTypes": [...], "maxSizeMb": 2|10 }`
- matching: `{ "shuffleLeft": true, "shuffleRight": true }`
#### `question_answer` table
Add:
- `payload` (`jsonb`, default `{}`, not null)
Keep existing `answers[]` and `open_answer` for backward compatibility during migration; new types write to `payload`.
#### `exercise` table
Add:
- `max_attempts` (`integer`, default `1`)
- `attempt_scoring_rule` (`varchar`, default `latest`)
- `show_correct_answers` (`varchar`, default `after_grading`)
- `timer_seconds` (`integer`, nullable)
- `randomize_question_order` (`boolean`, default `false`)
- `randomize_option_order` (`boolean`, default `false`)
- `available_from` (`timestamptz`, nullable)
- `available_to` (`timestamptz`, nullable)
#### `submission` table
Add:
- `attempt_number` (`integer`, default `1`)
- `finalized_score` (`double precision`, nullable)
- `grading_mode` (`varchar`, default `auto_or_manual`)
### 3. Validation Layer
Update/expand `packages/utils/src/validation/exercise/`:
- `question-base.ts` (shared prompt/points/rules)
- `question-true-false.ts`
- `question-short-answer.ts`
- `question-numeric.ts`
- `question-fill-blank.ts`
- `question-file-upload.ts`
- `question-matching.ts`
- `question-ordering.ts`
- `question-hotspot.ts`
- compose into:
- `ZExerciseCreate`
- `ZExerciseUpdate`
- `ZExerciseSubmissionCreate`
Validation must run:
- in API routes
- in dashboard before submit/update
- in grading service for defensive checks
### 4. API Layer Changes
Primary touchpoints:
- `apps/api/src/routes/course/exercise.ts`
- `apps/api/src/services/exercise/exercise.ts`
- `apps/api/src/services/exercise/utils.ts`
- `apps/api/src/services/submission/submission.ts`
- `packages/db/src/queries/exercise/exercise.ts`
Key changes:
1. Accept/store question `settings` and `scoringMode`.
2. Support typed answer payload writes to `question_answer.payload`.
3. Enforce attempt limits before creating a submission.
4. Compute final grade using `attemptScoringRule`.
5. Auto/manual grading decision based on question mix.
6. Support file-response references through existing assets model.
### 5. Dashboard Layer Changes
Primary touchpoints:
- `packages/ui/src/custom/exercise-question/*` (new shared UI component set; components only)
- `packages/ui/src/index.ts`
- `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`
- `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte`
- `apps/dashboard/src/lib/features/course/components/exercise/functions.ts`
- `apps/dashboard/src/lib/features/course/components/exercise/store.ts`
- `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts`
- `apps/dashboard/src/lib/features/course/utils/types.ts`
- translations in `apps/dashboard/src/lib/utils/translations/en.json`
Refactor plan:
1. Introduce registry/contracts in `@cio/question-types` and map question type keys to shared UI renderers.
2. Keep existing mode structure (`edit`, `view`, `preview`) but remove hardcoded `if RADIO/CHECKBOX/TEXTAREA` branching.
3. Add type-specific renderer components under `packages/ui/src/custom/exercise-question`.
4. Add translation keys for new types and setting labels.
5. Add exercise-level `auto-grade` toggle with eligibility guard and hint message when blocked by manual-only question types.
### 6. Legacy Compatibility and Migration
1. Existing exercises keep working without migration rewrite of question data.
2. New columns default safely (`{}` / false / null / default enums).
3. `question_type` seed extends with new typenames.
4. Replace hardcoded numeric assumptions where present.
Known cleanup item discovered during audit:
- `apps/api/src/services/course/clone.ts` uses `QUESTION_TYPE_TEXTAREA = 2`, while shared constant maps `TEXTAREA = 3`.
- This must be corrected before rollout to avoid option-cloning regressions for paragraph questions.
## Progress Update (Feb 26, 2026)
Completed in branch:
1. Shared package contracts/registry implemented in `@cio/question-types`.
2. Dashboard renderer logic centralized via shared render contracts and shared `@cio/ui/custom/exercise-question` components.
3. Shared question renderer component files implemented for legacy and expanded question types.
4. Clone service question-type ID mismatch fixed to use shared constants.
Still pending for grading engine milestone:
1. End-to-end server auto-grading engine across non-legacy types.
2. Exercise-level `auto-grade` toggle + disabled-state hint behavior.
3. Full per-type validation/attempt-rule enforcement rollout in API.
## Rollout Plan
### Phase 1 (P0)
Question types:
- True/False
- Short Answer
- Numeric
- Fill in the Blank
- File Upload
Required deliverables:
1. DB columns for settings/payload/attempt controls.
2. Validation and API support for P0 types.
3. Dashboard renderer registry + P0 components.
4. Attempt scoring rule support.
5. Migration + seed updates.
### Phase 2 (P1)
Question types:
- Matching
- Ordering/Sequence
Required deliverables:
1. Partial-credit grading helpers.
2. Enhanced analytics aggregation for pair/order correctness.
3. UX polish for drag/drop and accessibility.
### Phase 3 (P2)
Question types:
- Hotspot/Image Labeling
Required deliverables:
1. Image coordinate mapping schema.
2. Responsive touch/mobile interaction hardening.
3. Additional grading explainability in teacher UI.
## Testing Requirements
### API
- Schema validation tests for each type (create/update/submit).
- Grading tests:
- all-or-nothing
- partial-credit
- manual-required fallback
- Attempt rule tests (`highest`, `latest`, `average`).
- Upload constraint tests by plan limit.
### Dashboard
- Component tests for each type renderer in edit/take modes.
- Store tests for autosave and answer serialization.
- Integration tests for submission payload generation.
### Regression
- Existing three types must pass unchanged behavior tests.
- Exercise cloning must preserve all supported question types correctly.
## Build and Verification Commands
1. `pnpm --filter @cio/db build`
2. `pnpm --filter @cio/utils build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/dashboard build`
## Success Metrics (90 Days Post-Launch)
1. 40% of newly created exercises include at least one new type.
2. 25% reduction in average grading time per exercise.
3. 20% increase in completion for assignment-heavy exercises.
4. 30% increase in auto-graded question share in test exercises.
## Risks and Mitigations
1. Hardcoded question-type IDs may cause logic drift.
- Mitigation: canonical registry + typename-based mapping.
2. Rich payload types can increase complexity in submission parsing.
- Mitigation: strict per-type schema and shared serializer/deserializer utilities.
3. File uploads can introduce abuse/security risk.
- Mitigation: MIME/size validation, one-file v1 cap, server-side checks, existing asset safeguards.
## Open Questions
1. Should `short answer` support optional case-insensitive exact-match auto-grading in v1.1?
2. For fill-in-the-blank, should punctuation normalization be strict or relaxed by default?
3. Should hotspot remain P2 or shift post-P2 depending on demand?
@@ -0,0 +1,245 @@
# Exercise Question Types Expansion - Implementation Plan
## Scope Lock (v1)
1. Keep legacy `RADIO`, `CHECKBOX`, `TEXTAREA` behavior backward compatible.
2. Deliver P0 question types first: `TRUE_FALSE`, `SHORT_ANSWER`, `NUMERIC`, `FILL_BLANK`, `FILE_UPLOAD`.
3. Manual grading is required when exercise includes `SHORT_ANSWER` or `TEXTAREA`.
4. Attempt rules are exercise-level: `maxAttempts`, `attemptScoringRule` (`highest`, `latest`, `average`).
5. Correct-answer visibility is exercise-level policy.
6. File upload is one file per question in v1.
7. Upload limits are plan based: free `2MB`, paid `10MB`.
8. Use one canonical question-type registry to remove scattered hardcoded numeric IDs.
9. Existing submissions continue to read from `answers[]` and `openAnswer` during migration.
10. New type-rich answers write to `question_answer.payload`.
11. DB update policy for this plan: apply DB changes only in `packages/db/src/schema.ts`; do not generate new files in `packages/db/src/migrations`.
12. Question-type registry/contracts/state/serialization logic must live in `@cio/question-types`; `@cio/ui` must stay components-only.
13. Shared packages must be app-agnostic (no dashboard-local imports), so other apps can build exercise/form products from the same package APIs.
14. Exercise-level `auto-grade` toggle can be enabled only when all active questions are auto-gradable; if not, it stays disabled with a hint message.
15. Grading engine remains in `apps/api` as backend source of truth and is locked behind API contracts; no standalone grading package is introduced.
16. Submission grading workflow truth uses explicit `submission.gradingState` and `submission.overallStatus`; legacy `submission.statusId` is retained as board compatibility projection only.
17. Initial grading state on student submit is always `queued`.
18. Server-owned grading transitions are: `queued -> processing -> completed`, `queued -> processing -> awaiting_manual -> completed`, and `queued -> processing -> failed -> queued` (retry path).
19. `overallStatus` classification values are `auto_graded`, `manual_required`, and `hybrid`, computed deterministically from attempt snapshot and question capabilities.
20. Client routes do not directly own grading workflow transitions; API/service layer validates and applies allowed transitions.
21. Renderer filenames must not repeat mode suffix; canonical naming is `renderers/{question-type}/{mode}.svelte` (for example `renderers/true-false/edit.svelte`, not `true-false-edit.svelte`).
22. `@cio/ui/custom/exercise-question` must not contain hardcoded user-facing English strings (labels, helper text, placeholders, tooltips, validation messages, sr-only text); host app provides copy through render contracts/props.
## Delivery Phases
1. Phase A: Foundation (schema, seeds, shared registry, validation contracts)
2. Phase B: API + grading + submission state engine for P0
3. Phase C: Dashboard authoring/taking/grading UX for P0
4. Phase D: P1 types (`MATCHING`, `ORDERING`)
5. Phase E: P2 type (`HOTSPOT`)
6. Phase F: Regression hardening, rollout, and monitoring
## Shared Package File Contract (Cross-App Reuse)
These files are required deliverables so any app in the monorepo can consume shared packages and build a standalone exercise/forms product.
### `@cio/question-types` package files
1. `packages/question-types/src/question-type-keys.ts`
2. `packages/question-types/src/question-type-types.ts`
3. `packages/question-types/src/question-type-registry.ts`
4. `packages/question-types/src/question-type-lookup.ts`
5. `packages/question-types/src/question-type-capabilities.ts`
6. `packages/question-types/src/question-answer-payload.ts`
7. `packages/question-types/src/question-scoring-rules.ts`
8. `packages/question-types/src/exercise-types.ts`
9. `packages/question-types/src/render-contract.ts`
10. `packages/question-types/src/form-state.ts`
11. `packages/question-types/src/answer-serializer.ts`
12. `packages/question-types/src/renderer-registry.ts`
13. `packages/question-types/src/index.ts`
14. `packages/question-types/package.json` (package name: `@cio/question-types`)
### `@cio/ui` shared exercise-question component files (core)
1. `packages/ui/src/custom/exercise-question/question-form-root.svelte`
2. `packages/ui/src/custom/exercise-question/question-navigation.svelte`
3. `packages/ui/src/custom/exercise-question/question-renderer.svelte`
4. `packages/ui/src/custom/exercise-question/question-list.svelte`
5. `packages/ui/src/custom/exercise-question/renderer-contract.ts`
6. `packages/ui/src/custom/exercise-question/index.ts`
7. `packages/ui/src/index.ts` (exports `./custom/exercise-question`)
### `@cio/ui` P0 renderer files
1. `packages/ui/src/custom/exercise-question/renderers/radio/edit.svelte`
2. `packages/ui/src/custom/exercise-question/renderers/checkbox/edit.svelte`
3. `packages/ui/src/custom/exercise-question/renderers/textarea/edit.svelte`
4. `packages/ui/src/custom/exercise-question/renderers/radio/take.svelte`
5. `packages/ui/src/custom/exercise-question/renderers/checkbox/take.svelte`
6. `packages/ui/src/custom/exercise-question/renderers/textarea/take.svelte`
7. `packages/ui/src/custom/exercise-question/renderers/radio/preview.svelte`
8. `packages/ui/src/custom/exercise-question/renderers/checkbox/preview.svelte`
9. `packages/ui/src/custom/exercise-question/renderers/textarea/preview.svelte`
10. `packages/ui/src/custom/exercise-question/renderers/true-false/edit.svelte`
11. `packages/ui/src/custom/exercise-question/renderers/short-answer/edit.svelte`
12. `packages/ui/src/custom/exercise-question/renderers/numeric/edit.svelte`
13. `packages/ui/src/custom/exercise-question/renderers/fill-blank/edit.svelte`
14. `packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte`
15. `packages/ui/src/custom/exercise-question/renderers/true-false/take.svelte`
16. `packages/ui/src/custom/exercise-question/renderers/short-answer/take.svelte`
17. `packages/ui/src/custom/exercise-question/renderers/numeric/take.svelte`
18. `packages/ui/src/custom/exercise-question/renderers/fill-blank/take.svelte`
19. `packages/ui/src/custom/exercise-question/renderers/file-upload/take.svelte`
20. `packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte`
21. `packages/ui/src/custom/exercise-question/renderers/short-answer/preview.svelte`
22. `packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte`
23. `packages/ui/src/custom/exercise-question/renderers/fill-blank/preview.svelte`
24. `packages/ui/src/custom/exercise-question/renderers/file-upload/preview.svelte`
### `@cio/ui` P1/P2 renderer files (created when phase starts)
1. `packages/ui/src/custom/exercise-question/renderers/matching/edit.svelte`
2. `packages/ui/src/custom/exercise-question/renderers/matching/take.svelte`
3. `packages/ui/src/custom/exercise-question/renderers/matching/preview.svelte`
4. `packages/ui/src/custom/exercise-question/renderers/ordering/edit.svelte`
5. `packages/ui/src/custom/exercise-question/renderers/ordering/take.svelte`
6. `packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte`
7. `packages/ui/src/custom/exercise-question/renderers/hotspot/edit.svelte`
8. `packages/ui/src/custom/exercise-question/renderers/hotspot/take.svelte`
9. `packages/ui/src/custom/exercise-question/renderers/hotspot/preview.svelte`
### Renderer Filename Convention (Locked)
1. Question type is expressed by folder: `renderers/{question-type}/`.
2. Mode is expressed only by filename: `edit.svelte`, `take.svelte`, `preview.svelte`.
3. Do not use mode-first folders (`renderers/edit/*`) or redundant suffixes like `-edit`, `-take`, or `-preview`.
### UI Copy Ownership (Locked)
1. Shared UI renderers are copyless primitives and must not embed end-user copy.
2. All labels/help/error/tooltip/placeholder/sr-only text must be provided by host app (dashboard) via typed contract props.
3. Translation key resolution stays in host apps (`apps/dashboard`), not in `packages/ui`.
4. Renderer utilities in `packages/ui` must not generate user-facing fallback words (for example `Step`); fallback semantics must come from host-provided copy.
## Ticket Breakdown
| ID | Phase | Area | Task | Key Files | Dependencies | Done When |
| --- | --- | --- | --- | --- | --- | --- |
| EX-A1 | A | DB Schema | Add new columns for `exercise`, `question`, `question_answer`, `submission` (attempt controls, settings, payload, grading metadata including workflow/classification fields) in schema only | `packages/db/src/schema.ts` | None | Schema changes compile via package builds and no new migration files are generated |
| EX-A2 | A | DB Seed | Extend `question_type` seed with new typenames/labels | `packages/db/src/utils/seed/questionType.ts` | EX-A1 | Re-seeding inserts missing types idempotently |
| EX-A3 | A | Shared Registry | Create canonical question type registry and metadata in dedicated package (`@cio/question-types`) | `packages/question-types/src/question-type-keys.ts`, `packages/question-types/src/question-type-types.ts`, `packages/question-types/src/question-type-registry.ts`, `packages/question-types/src/question-type-lookup.ts`, `packages/question-types/src/question-type-capabilities.ts`, `packages/question-types/src/question-answer-payload.ts`, `packages/question-types/src/question-scoring-rules.ts`, `packages/question-types/src/exercise-types.ts`, `packages/question-types/src/render-contract.ts`, `packages/question-types/src/form-state.ts`, `packages/question-types/src/answer-serializer.ts`, `packages/question-types/src/renderer-registry.ts`, `packages/question-types/src/index.ts`, `packages/question-types/package.json` | EX-A2 | API and dashboard consume one source of truth for type keys/traits and all shared registry files exist |
| EX-A4 | A | Validation | Replace fragile numeric-only assumptions with registry-backed schemas and enums | `packages/utils/src/validation/constants.ts`, `packages/utils/src/validation/exercise/exercise.ts` | EX-A3 | Validation accepts legacy + new types and rejects invalid per-type payloads |
| EX-A5 | A | DB Queries | Add read/write support for new columns and typed answer payload in query layer | `packages/db/src/queries/exercise/exercise.ts`, `packages/db/src/queries/submission/*` | EX-A1 | Query methods persist and return settings/payload/attempt fields |
| EX-A6 | A | Clone Safety | Fix clone question type mismatch (`TEXTAREA` id bug) using shared constant/typename | `apps/api/src/services/course/clone.ts` | EX-A3 | Exercise clone preserves paragraph questions and options correctly |
| EX-A7 | A | Shared Copy Contract | Add typed i18n/copy contract for exercise-question renderer labels and messages in shared question-types package | `packages/question-types/src/render-contract.ts`, `packages/question-types/src/exercise-types.ts`, `packages/question-types/src/index.ts`, `packages/question-types/src/renderer-registry.ts` | EX-A3 | Host apps can pass a strongly typed labels object that covers shared renderer copy needs |
| EX-B1 | B | API Validation | Split exercise schemas into base + per-type authoring/submission schemas | `packages/utils/src/validation/exercise/*` | EX-A4 | Route-level validation is discriminated by question type |
| EX-B2 | B | Exercise Service | Support create/update with `question.settings`, `scoringMode`, `manualGradingRequired`, and exercise attempt settings | `apps/api/src/services/exercise/exercise.ts`, `apps/api/src/services/exercise/utils.ts` | EX-A5, EX-B1 | Create/update persists all new fields and returns normalized exercise DTO |
| EX-B3 | B | Submission Service | Accept typed answers into `question_answer.payload` while preserving legacy fallback fields | `apps/api/src/services/submission/submission.ts` | EX-A5, EX-B1 | Submission creation supports mixed legacy/new question payloads |
| EX-B4 | B | Grading Engine | Implement auto-grading for P0 objective types (`TRUE_FALSE`, `NUMERIC`, `FILL_BLANK`) | `apps/api/src/services/submission/submission.ts`, `apps/api/src/services/exercise/utils.ts` | EX-B3 | Auto-grade computed at question level with configurable scoring mode |
| EX-B5 | B | Attempt Rules | Enforce `maxAttempts` and compute final score by `attemptScoringRule` | `apps/api/src/services/submission/submission.ts`, `packages/db/src/queries/submission/*` | EX-B3 | New attempts blocked when limit reached; final score follows configured rule |
| EX-B6 | B | Upload Handling | Add file-upload answer flow with allowed type/size validation and asset reference storage | `apps/api/src/routes/course/exercise.ts`, `apps/api/src/services/submission/submission.ts` | EX-B3 | Invalid upload types/sizes rejected; valid uploads linked in answer payload |
| EX-B7 | B | Routes | Update exercise and submission routes for new request/response contracts | `apps/api/src/routes/course/exercise.ts`, `apps/api/src/routes/course/submission.ts` | EX-B2, EX-B3 | RPC contract supports new fields without breaking legacy clients |
| EX-B8 | B | Manual Grading | Route mixed/manual exercises to manual grading path and allow override of auto score | `apps/api/src/services/submission/submission.ts`, `apps/api/src/services/mark/gradebook.ts` | EX-B4 | Teacher can override and persist final grade on submission |
| EX-B9 | B | Auto-Grade Guardrail | Enforce exercise-level `auto-grade` eligibility in API/service layer using question-type capability checks | `apps/api/src/services/exercise/exercise.ts`, `apps/api/src/services/submission/submission.ts`, `packages/question-types/src/question-type-capabilities.ts` | EX-A3, EX-B2 | API rejects `auto-grade=true` when any active question type requires manual grading |
| EX-B10 | B | Submission Workflow | Implement API-owned grading state machine and deterministic `overallStatus` computation on submission lifecycle | `packages/db/src/schema.ts`, `packages/utils/src/validation/submission/submission.ts`, `packages/db/src/queries/submission/submission.ts`, `apps/api/src/services/submission/submission.ts`, `apps/api/src/routes/course/submission.ts` | EX-A1, EX-B3, EX-B4, EX-B8 | Submit starts `queued`, transitions are validated server-side, and invalid direct state jumps are rejected |
| EX-B11 | B | Board Compatibility | Preserve existing 3 board columns by projecting workflow state to legacy `statusId` (`1=Submitted`, `2=In Progress`, `3=Graded`) in API responses | `apps/api/src/services/submission/submission.ts`, `apps/api/src/routes/course/submission.ts`, `packages/db/src/queries/submission/submission.ts` | EX-B10 | Submission board endpoints continue returning stable `statusId` while grading truth lives in workflow/classification fields |
| EX-C1 | C | Dashboard Types | Add request/response/data types for new exercise payloads in feature types | `apps/dashboard/src/lib/features/course/utils/types.ts` | EX-B7 | Frontend types inferred from API contract only |
| EX-C2 | C | Dashboard API | Update exercise API class to validate/send new create/update/submit payload shapes | `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts` | EX-C1 | Create/update/submit flows pass new schema checks client-side |
| EX-C3 | C | Renderer Registry | Implement registry logic in `@cio/question-types` and wire dashboard to map question-type keys to shared `@cio/ui` renderer components | `packages/question-types/src/renderer-registry.ts`, `packages/question-types/src/exercise-types.ts`, `packages/question-types/src/index.ts`, `packages/ui/src/custom/exercise-question/index.ts`, `packages/ui/src/index.ts`, `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` | EX-A3 | No hardcoded `if RADIO/CHECKBOX/TEXTAREA` branching remains; registry/contracts come from `@cio/question-types` and components from `@cio/ui` |
| EX-C4 | C | Edit UX | Refactor authoring UI to use registry and per-type settings editors | `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` | EX-C3, EX-C2 | Instructor can configure P0 types and per-type settings in one flow |
| EX-C5 | C | Take UX | Refactor learner view to use registry and typed answer serializers | `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/functions.ts` | EX-C3, EX-C2 | Student can answer mixed-type exercises and submit valid payload |
| EX-C6 | C | Store | Update exercise store/autosave model for typed answers and migration-safe hydration | `apps/dashboard/src/lib/features/course/components/exercise/store.ts` | EX-C5 | Autosave/restore works for legacy and new type payloads |
| EX-C7 | C | P0 Components | Add new UI question components (`true-false`, `short-answer`, `numeric`, `fill-blank`, `file-upload`) in shared UI package | `packages/ui/src/custom/exercise-question/renderers/true-false/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/short-answer/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/numeric/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/fill-blank/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/true-false/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/short-answer/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/numeric/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/fill-blank/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/file-upload/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/true-false/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/short-answer/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/numeric/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/fill-blank/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/file-upload/preview.svelte`, `packages/ui/src/custom/exercise-question/index.ts`, `packages/ui/src/index.ts` | EX-C3 | Edit/view/preview components exist for all P0 types and are exported from `@cio/ui` |
| EX-C8 | C | Grading UI | Update submission/grading views to display typed answers from payload | `apps/dashboard/src/lib/features/course/components/exercise/submissions/*` | EX-B8, EX-C5 | Teacher can review and grade all P0 answer shapes |
| EX-C9 | C | i18n | Add translation keys for new types/settings/status copy | `apps/dashboard/src/lib/utils/translations/en.json` | EX-C4, EX-C5 | No new hardcoded user-facing text |
| EX-C10 | C | Accessibility | Validate keyboard navigation and mobile rendering for new question interactions in shared UI component package | `packages/ui/src/custom/exercise-question/*` | EX-C7 | P0 UI passes accessibility checks and mobile smoke tests |
| EX-C11 | C | Edit UX Guardrail | Add exercise-level `auto-grade` toggle, disable state, and hint copy when question mix includes manual-only types | `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts`, `apps/dashboard/src/lib/utils/translations/en.json` | EX-C3, EX-B9 | Toggle enables only when all active question types are auto-gradable; hint appears under toggle when blocked |
| EX-C12 | C | Submission Board UX | Consume API workflow data while keeping 3-column board UX, and surface failed/manual-needed hints under In Progress column | `apps/dashboard/src/lib/features/course/pages/submissions.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/mark-exercise-modal.svelte`, `apps/dashboard/src/lib/features/course/utils/types.ts`, `apps/dashboard/src/lib/features/course/api/submission.svelte.ts`, `apps/dashboard/src/lib/utils/translations/en.json` | EX-B10, EX-B11 | Board still behaves as `1/2/3` columns, with additional workflow clarity from API-provided grading fields |
| EX-C13 | C | Renderer Structure Refactor | Refactor shared renderer files to question-type folders with mode file names and update imports/registry/storybook references | `packages/ui/src/custom/exercise-question/renderers/*/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/preview.svelte`, `packages/ui/src/custom/exercise-question/index.ts`, `packages/question-types/src/renderer-registry.ts`, `packages/storybook/src/molecules/exercise-question/*`, `apps/dashboard/src/lib/features/course/components/exercise/*` | EX-C3, EX-C7 | Renderer paths use `renderers/{question-type}/{mode}.svelte`; no mode-first folders or mode-suffixed filenames remain |
| EX-C14 | C | Copy Externalization | Remove hardcoded user-facing copy from shared question renderer files and wire host-provided labels from dashboard translations | `packages/ui/src/custom/exercise-question/question-renderer.svelte`, `packages/ui/src/custom/exercise-question/question-navigation.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/*/preview.svelte`, `packages/ui/src/custom/exercise-question/renderers/ordering-utils.ts`, `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`, `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte`, `apps/dashboard/src/lib/features/course/utils/types.ts`, `apps/dashboard/src/lib/utils/translations/en.json`, `packages/storybook/src/molecules/exercise-question/*` | EX-A7, EX-C3 | Shared renderer package has no hardcoded end-user copy; dashboard injects all labels/messages from translation keys through typed contracts |
| EX-D1 | D | Matching Type | Implement `MATCHING` schema, authoring/taking UI in shared UI component package, serializer, and grader | validation, API service, `packages/ui/src/custom/exercise-question/renderers/matching/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/matching/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/matching/preview.svelte` | EX-C7, EX-B4 | Matching questions can be authored, submitted, and auto-graded |
| EX-D2 | D | Ordering Type | Implement `ORDERING` schema, drag-sort UI in shared UI component package, serializer, and grader | validation, API service, `packages/ui/src/custom/exercise-question/renderers/ordering/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/ordering/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/ordering/preview.svelte` | EX-C7, EX-B4 | Ordering questions can be authored, submitted, and auto-graded |
| EX-D3 | D | Partial Credit | Add partial-credit policy controls and explainability in grading details | `apps/api/src/services/submission/submission.ts`, submissions UI | EX-D1, EX-D2 | Teacher sees per-question partial-credit breakdown |
| EX-E1 | E | Hotspot Type | Implement `HOTSPOT` schema, image coordinate payload, authoring/taking UI in shared UI component package, grader | validation, API service, `packages/ui/src/custom/exercise-question/renderers/hotspot/edit.svelte`, `packages/ui/src/custom/exercise-question/renderers/hotspot/take.svelte`, `packages/ui/src/custom/exercise-question/renderers/hotspot/preview.svelte` | EX-C7 | Hotspot is fully supported end-to-end |
| EX-E2 | E | Hotspot Hardening | Improve responsive/touch behavior and precision tolerances for hotspot grading | hotspot UI + grading utilities | EX-E1 | Hotspot works reliably on mobile/tablet and desktop |
| EX-F1 | F | API Tests | Add create/update/submit tests per type, attempt rules, manual/auto grading paths | `apps/api/tests/*`, `packages/utils/src/validation/*` | EX-B8, EX-D3, EX-E1 | Core API behavior covered with passing tests |
| EX-F2 | F | Dashboard Tests | Add component/store/integration tests for authoring, taking, autosave, submission payloads across dashboard integration and shared UI component package | `apps/dashboard/src/lib/features/course/components/exercise/*`, `packages/ui/src/custom/exercise-question/*` | EX-C10, EX-D2, EX-E2 | UI and store behavior stable for legacy + new types |
| EX-F3 | F | Regression | Add clone and backward-compat regression tests for legacy question data | API/services/db test files | EX-A6, EX-F1 | Existing exercises run unchanged after rollout |
| EX-F4 | F | Rollout | Ship rollout checklist, migration runbook, and monitoring for submission/grading errors | `prd/exercise-question-types/README.md`, ops docs/config | EX-F1, EX-F2, EX-F3 | Controlled release enabled with rollback steps documented |
## Acceptance Criteria by Phase
### Phase A
1. Schema changes are limited to `packages/db/src/schema.ts` and no migration files are generated.
2. `question_type` seed is idempotent and includes all planned typenames.
3. Shared registry is imported by both API and dashboard.
4. Clone service no longer depends on mismatched hardcoded paragraph ID.
5. Shared renderer copy-contract types are defined in `@cio/question-types` and exported for host apps.
### Phase B
1. Exercise create/update APIs accept and return new settings fields.
2. Submission API accepts typed payloads and still reads legacy answer shape.
3. Attempt limits and scoring rules are enforced server-side.
4. File-upload answer validations enforce plan limits and allowed types.
5. API/service layer blocks exercise-level `auto-grade` when question mix includes any manual-only type.
6. Student submit always creates submission with workflow state `queued` and board-compatible `statusId=1`.
7. API enforces valid grading transitions only (`queued -> processing -> ...`), with retry path (`failed -> queued`), and rejects invalid direct client transitions.
8. `overallStatus` is computed deterministically as `auto_graded`, `manual_required`, or `hybrid`.
9. API projects workflow states to board `statusId` mapping (`queued=1`, `processing|awaiting_manual|failed=2`, `completed=3`).
### Phase C
1. Authoring and learner UIs support all P0 types.
2. Renderer registry removes hardcoded question-type branching in core screens.
3. Dashboard consumes registry/contracts from `@cio/question-types` and renderer components from `@cio/ui`, not dashboard-local question renderer files.
4. Grading UI can render typed payloads for teacher review.
5. No hardcoded UI copy is introduced.
6. Shared package files listed in `Shared Package File Contract` are implemented and exported.
7. Exercise-level `auto-grade` toggle is disabled with a clear hint when question mix is not fully auto-gradable.
8. Submission board continues to use 3 columns from backend-projected `statusId`, while exposing workflow hints (`failed`, `awaiting_manual`) without changing board IA.
9. Renderer files/imports follow canonical type-first paths: `renderers/{question-type}/{mode}.svelte`.
10. `@cio/ui/custom/exercise-question` does not contain hardcoded end-user copy; dashboard passes translated labels/messages via typed render contracts.
### Phase D
1. Matching and ordering are fully authorable/submittable/gradable.
2. Partial-credit configuration and score explanation are visible to instructors.
### Phase E
1. Hotspot works with responsive image rendering and touch input.
2. Hotspot grading handles coordinate tolerances consistently.
### Phase F
1. Regression tests cover legacy three types and mixed-type exercises.
2. Rollout checklist includes migration, monitoring, and rollback steps.
## Verification Commands
1. `pnpm --filter @cio/db build`
2. `pnpm --filter @cio/utils build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/dashboard build`
## Suggested First Sprint Slice
1. EX-A1 to EX-A7 (schema, seed, registry, validation foundation, clone fix, shared copy contract)
2. EX-B1 to EX-B5 + EX-B10 + EX-B11 (P0 API contracts, typed submission, attempt/scoring enforcement, workflow state machine, board projection compatibility)
3. EX-C1 to EX-C6 + EX-C14 (frontend types/API + renderer registry + core edit/view/store refactor + shared copy externalization)
This slice establishes the core contracts and de-risks the hardest migration paths before adding all P0 components and grading UI polish.
## Implementation Progress (Updated Feb 26, 2026)
Completed ticket IDs:
1. `EX-A3` Shared question-type package (`@cio/question-types`) with registry/contracts/helpers.
2. `EX-A6` Clone safety bug fix using shared question type IDs.
3. `EX-A7` Shared copy contract typing for renderer labels/messages (`ExerciseQuestionLabels`) in `@cio/question-types`.
4. `EX-C3` Dashboard/UI renderer registry centralization (`@cio/question-types` contracts + `@cio/ui` components).
5. `EX-C7` Shared `@cio/ui/custom/exercise-question` renderer component set delivered (includes P0 and additional renderer files used by current branch).
6. `EX-C13` Renderer structure refactor completed with canonical type-first paths (`renderers/{question-type}/{mode}.svelte`) and updated imports/exports/storybook wiring.
7. `EX-C14` Shared renderer copy externalization completed; dashboard now injects labels/messages from translations through typed contracts.
In-progress ticket IDs:
1. `EX-A4` Validation migration is partial (constants wired to shared IDs; full per-type schema split still pending).
2. `EX-C4` Authoring UX refactor is mostly done, but exercise-level grading controls/guards are not complete.
3. `EX-C5` Learner UX refactor is active; answer submission/grading remains mixed legacy behavior.
4. `EX-B10` Submission workflow state machine has been started (schema fields + transition validation + API projection wiring); lifecycle hardening and tests remain.
5. `EX-B11` Board compatibility projection has been started (workflow-to-legacy `statusId` projection in submission service); endpoint/test hardening remains.
6. `EX-C12` Dashboard board workflow hint wiring has been started (workflow hints + guarded transitions in board UI); full UX/polish and edge-case handling remain.
7. `EX-D1`, `EX-D2`, `EX-E1` UI renderers exist, but full API validation/submission/grader paths are not complete.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
# Lesson Page Edit Mode Ideation
## Purpose
Ensure that in lesson edit mode, teachers can edit the lesson title, lock/unlock the lesson, and delete the lesson directly from the lesson page.
## Data Sources Checked
- `apps/dashboard/src/lib/features/course/pages/lesson.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/delete-lesson-confirmation.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/store.ts`
- `apps/dashboard/src/lib/features/course/api/lesson.svelte.ts`
- `packages/db/src/schema.ts`
## Data Availability & Effort
Legend: ✅ available now, ⚠️ exists but not wired in UI, ❌ missing data
| Capability | Availability | Source / Notes | Effort | UI Placement | Should Implement |
| --- | --- | --- | --- | --- | --- |
| Edit lesson title | ⚠️ | `lessonApi.lesson.title` exists; lesson page does not edit title | Medium | Page header title (editable in edit mode) | [ ] |
| Lock / unlock lesson | ⚠️ | `lesson.isUnlocked` exists; update endpoint supports it | Medium | Header action toggle (teacher-only) | [ ] |
| Delete lesson | ⚠️ | `lessonApi.delete` + `DeleteLessonConfirmation` exists | Medium | Header action (trash icon + confirm) | [ ] |
## Kanban Board (Ideation Phase)
| Ticket | Title | Todo | In Progress | Verification | Done |
| --- | --- | --- | --- | --- | --- |
| T1 | Lesson edit: title inline editing | [ ] | [ ] | [ ] | [ ] |
| T2 | Lesson edit: lock/unlock toggle | [ ] | [ ] | [ ] | [ ] |
| T3 | Lesson edit: delete action + confirmation | [ ] | [ ] | [ ] | [ ] |
## Ticket Breakdown (Backend/UI Split)
| Parent Ticket | Backend Ticket | UI Ticket |
| --- | --- | --- |
| T1 | T1-BE: confirm update API accepts `title` (existing) | T1-UI: editable header title + save wiring |
| T2 | T2-BE: confirm update API accepts `isUnlocked` (existing) | T2-UI: lock toggle + state sync |
| T3 | T3-BE: confirm delete API route (existing) | T3-UI: add delete button + confirmation modal |
+154
View File
@@ -0,0 +1,154 @@
# Lessons List Page Ideation
## Purpose
Define the most useful, straight-to-the-point information to show in a non-grouped lessons list page (lessons + exercises), split by persona and course type, plus a data availability audit and implementation effort.
## Data Sources Checked
- `apps/dashboard/src/lib/features/course/pages/lessons.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte`
- `apps/api/src/services/course/course.ts`
- `apps/api/src/services/course/utils.ts`
- `packages/db/src/queries/course/content.ts`
- `packages/db/src/schema.ts`
## Data Availability & Effort
Legend: ✅ available now, ⚠️ exists but not in list payload, ❌ missing data
### Teachers — Generic
| Field | Content Type (Lesson/Exercise) | Availability | Source / Notes | Effort | Visualization | Should Implement |
| --- | --- | --- | --- | --- | --- | --- |
| Title | Both | ✅ | `CourseContentItem.title` | Low | Primary title text | [x] |
| Item type icon | Both | ✅ | `CourseContentItem.type` | Low | Lesson/Exercise icon (no text) | [x] |
| Quick actions | Both | ✅ | UI-only | Low | Action buttons (Edit/Assign/Preview) | [x] |
| Lock status (locked/unlocked) | Both | ✅ | `isUnlocked` on lessons/exercises | Low | Lock/unlock icon (clickable) | [x] |
| Resources attached | Lesson | ✅ | `documentsCount` + `videosCount` in list payload | Medium | Icons with counts | [x] |
| Question count | Exercise | ✅ | `questionCount` in list payload | Medium–High | “5 questions” chip | [x] |
### Teachers — Live Class
| Field | Content Type (Lesson/Exercise) | Availability | Source / Notes | Effort | Visualization | Should Implement |
| --- | --- | --- | --- | --- | --- | --- |
| Scheduled date/time | Lesson | ✅ | `lessonAt` in list payload | Medium | Date/time chip + calendar icon | [x] |
| Join/host link | Lesson | ✅ | `callUrl` in list payload | Medium | Primary CTA button | [x] |
| Submissions count | Exercise | ⚠️ | Submissions endpoints exist, not per exercise | High | Count pill + “needs review” tag | [ ] |
| Attendance snapshot | Lesson | ❌ | Attendance has only upsert, no GET | High | Mini stat: “12/18 attended” | [ ] |
### Teachers — Self-Paced
| Field | Content Type (Lesson/Exercise) | Availability | Source / Notes | Effort | Visualization | Should Implement |
| --- | --- | --- | --- | --- | --- | --- |
| Status (Not started/Submitted/Graded) | Exercise | ⚠️ | Needs submission grading state in list payload | Medium–High | Status badge | [ ] |
| Due date / window | Exercise | ✅ | `dueBy` in list payload | Medium | Date chip | [x] |
| Due date / window | Lesson | ❌ | No lesson due date field | High | Date chip | [ ] |
| Submissions pending review | Exercise | ⚠️ | “for-grading” exists, not per exercise | High | Count + alert pill | [ ] |
| Last student activity | Both | ❌ | No per-item activity log | High | “Last activity: 2d ago” text | [ ] |
### Students — Generic
| Field | Content Type (Lesson/Exercise) | Availability | Source / Notes | Effort | Visualization | Should Implement |
| --- | --- | --- | --- | --- | --- | --- |
| Title | Both | ✅ | `CourseContentItem.title` | Low | Primary title text | [x] |
| Item type icon | Both | ✅ | `CourseContentItem.type` | Low | Lesson/Exercise icon (no text) | [x] |
| Lock status (locked/unlocked) | Both | ✅ | `isUnlocked` on lessons/exercises (read-only) | Low | Lock icon (disabled) | [x] |
| Lesson content (materials) | Lesson | ✅ | `documentsCount` + `videosCount` in list payload | Medium | Icons with counts | [x] |
| Completion state (Complete/Not complete) | Lesson | ✅ | `isComplete` | Low | Status badge | [x] |
| Submission state (Not started/Submitted/Graded) | Exercise | ⚠️ | Needs submission/grading state | Medium | Status badge | [ ] |
| Next step | Both | ✅ | Derive from `isComplete`/`isUnlocked` | Low | “Continue” button | [x] |
### Students — Live Class
| Field | Content Type (Lesson/Exercise) | Availability | Source / Notes | Effort | Visualization | Should Implement |
| --- | --- | --- | --- | --- | --- | --- |
| Scheduled date/time | Lesson | ✅ | `lessonAt` in list payload | Medium | Date/time chip | [x] |
| Join link/button | Lesson | ✅ | `callUrl` in list payload | Medium | Primary CTA button | [x] |
| Lesson content (materials) | Lesson | ✅ | `documentsCount` + `videosCount` in list payload | Medium | Icons with counts | [x] |
| Instructor name | Lesson | ⚠️ | `lesson.teacherId` exists; needs profile join | High | Secondary label | [ ] |
| Notes / prep checklist | Lesson | ✅ | `hasNoteContent` in list payload (derived from lesson note translations) | Medium | Note/`no-content` chip | [x] |
### Students — Self-Paced
| Field | Content Type (Lesson/Exercise) | Availability | Source / Notes | Effort | Visualization | Should Implement |
| --- | --- | --- | --- | --- | --- | --- |
| Due date | Exercise | ✅ | `dueBy` in list payload | Medium | Date chip | [x] |
| Due date | Lesson | ❌ | No lesson due date field | High | Date chip | [ ] |
| Estimated time | Both | ❌ | No field currently | High | “~30 min” text | [ ] |
## Implemented (2026-02-08)
### Field Contract Added to Course Content List Payload
- `lessonAt: string | null`
- `callUrl: string | null`
- `hasNoteContent: boolean | null`
- `videosCount: number | null`
- `documentsCount: number | null`
- `questionCount: number | null`
- `dueBy: string | null`
### UI Behavior Delivered
- Persona-first rendering for teachers/admin vs students in both list modes (grouped and non-grouped).
- Date rendering uses existing app formatter (`formatDate`); missing values are hidden.
- Lock toggle is teacher/admin-only; students get read-only lock state.
- Teacher/admin rows include schedule/join CTA for live class, resources count for lessons, question count and due date for exercises.
- Student rows include completion state (lesson), materials count (lesson), note/no-content status (lesson), schedule/join (live), due date (self-paced), and next-step CTA.
### Verification Run
- `pnpm --filter @cio/dashboard build` ✅
- `pnpm --filter @cio/api build` ✅
## Visualization Recommendations (Summary)
- Use a **two-line row**: title, then meta chips.
- Status and time should be **chips/badges** aligned right or under title.
- Progress should be implied by status; avoid redundant percent bars.
- Resources and requirements should be **icons with counts**.
- Primary action should be a **single CTA** (Join / Continue / Review).
- Separate lesson items from exercise items (distinct sections or tabs).
## Kanban Board (Ideation Phase)
| Ticket | Title | Todo | In Progress | Verification | Done |
| --- | --- | --- | --- | --- | --- |
| T1 | Teachers: show lesson/exercise title | [ ] | [ ] | [x] | [x] |
| T2 | Teachers: show lesson/exercise type icon | [ ] | [ ] | [x] | [x] |
| T3 | Teachers: show quick actions | [ ] | [ ] | [x] | [x] |
| T4 | Teachers: show lock/unlock status | [ ] | [ ] | [x] | [x] |
| T5 | Teachers: show lesson resources count | [ ] | [ ] | [x] | [x] |
| T6 | Teachers: show exercise question count | [ ] | [ ] | [x] | [x] |
| T7 | Teachers live class: show schedule time | [ ] | [ ] | [x] | [x] |
| T8 | Teachers live class: show join/host link | [ ] | [ ] | [x] | [x] |
| T9 | Teachers self-paced: show exercise due date | [ ] | [ ] | [x] | [x] |
| T10 | Students: show lesson/exercise title | [ ] | [ ] | [x] | [x] |
| T11 | Students: show lesson/exercise type icon | [ ] | [ ] | [x] | [x] |
| T12 | Students: show lock status (read-only) | [ ] | [ ] | [x] | [x] |
| T13 | Students: show lesson materials count | [ ] | [ ] | [x] | [x] |
| T14 | Students: show lesson completion state | [ ] | [ ] | [x] | [x] |
| T15 | Students: show next-step action | [ ] | [ ] | [x] | [x] |
| T16 | Students live class: show schedule time | [ ] | [ ] | [x] | [x] |
| T17 | Students live class: show join button | [ ] | [ ] | [x] | [x] |
| T18 | Students live class: show lesson materials | [ ] | [ ] | [x] | [x] |
| T19 | Students self-paced: show exercise due date | [ ] | [ ] | [x] | [x] |
## Ticket Breakdown (Backend/UI Split)
| Parent Ticket | Backend Ticket | UI Ticket |
| --- | --- | --- |
| T1 | T1-BE: ensure list payload includes title (existing) | T1-UI: render title in list row |
| T2 | T2-BE: ensure item type present (existing) | T2-UI: show lesson/exercise icon |
| T3 | T3-BE: none | T3-UI: render actions per role |
| T4 | T4-BE: ensure `isUnlocked` for lessons/exercises (existing) | T4-UI: render lock status (teacher toggles) |
| T5 | T5-BE: add documents/videos counts to list payload | T5-UI: render lesson resources count |
| T6 | T6-BE: add exercise question count to list payload | T6-UI: render question count chip |
| T7 | T7-BE: add `lessonAt` to list payload | T7-UI: render schedule date/time |
| T8 | T8-BE: add `callUrl` to list payload | T8-UI: render join/host CTA |
| T9 | T9-BE: add `dueBy` to list payload | T9-UI: render exercise due date chip |
| T10 | T10-BE: ensure list payload includes title (existing) | T10-UI: render title in list row |
| T11 | T11-BE: ensure item type present (existing) | T11-UI: show lesson/exercise icon |
| T12 | T12-BE: ensure `isUnlocked` for lessons/exercises (existing) | T12-UI: render lock icon (read-only) |
| T13 | T13-BE: add documents/videos counts to list payload | T13-UI: render lesson materials count |
| T14 | T14-BE: ensure `isComplete` for lessons (existing) | T14-UI: render completion badge |
| T15 | T15-BE: none | T15-UI: render next-step CTA |
| T16 | T16-BE: add `lessonAt` to list payload | T16-UI: render schedule date/time |
| T17 | T17-BE: add `callUrl` to list payload | T17-UI: render join CTA |
| T18 | T18-BE: add documents/videos counts to list payload | T18-UI: render lesson materials count |
| T19 | T19-BE: add `dueBy` to list payload | T19-UI: render exercise due date chip |
+308
View File
@@ -0,0 +1,308 @@
# Media Manager PRD
## Purpose
Design and deliver an organization-level **Media Manager** so uploaded files become reusable, governable assets instead of content-local JSON blobs.
## Problem Statement
Today, uploads are attached directly inside content payloads (for example `lesson.videos`) and managed only from that local context.
This creates product and technical gaps:
- No organization-wide asset library.
- No reliable reuse across courses/lessons/exercises.
- No global usage map (where each asset is used).
- No global edit propagation (title/thumbnail/metadata updates).
- No accurate organization storage accounting.
- No one-click media export per organization.
## Decision Update
We will use a **separate canonical assets model**:
- `assets` table for organization-scoped asset identity and storage metadata.
- `asset_usages` table for where each asset is used.
`lesson.videos` remains temporarily for compatibility during migration.
## Current-State Audit
Legend: ✅ available now, ⚠️ partial/manual, ❌ missing
| Capability | Availability | Current State | Effort | Should Implement |
| --- | --- | --- | --- | --- |
| Upload media to content | ✅ | Added directly inside content JSON | Low | [x] |
| Reuse existing uploaded media | ⚠️ | Mostly manual copy/re-add flows | Medium | [x] |
| Org-wide asset library | ❌ | No canonical assets table/page | High | [x] |
| Cross-target usage visibility | ❌ | No usage graph | High | [x] |
| Global metadata updates | ❌ | Metadata is duplicated per content item | High | [x] |
| Accurate org storage usage | ❌ | No canonical `byte_size` aggregation | Medium | [x] |
| Safe delete/archive with usage guard | ❌ | Local removal only | Medium | [x] |
## Goals
1. Create canonical org-level assets in `assets`.
2. Support asset kinds beyond video (`document`, `image`, extensible).
3. Allow attaching assets to lessons, exercises, and nested content slots.
4. Provide organization-level browsing/search/filtering and usage visibility.
5. Support global metadata updates with consistent rendering across usages.
6. Enable accurate organization storage metrics from canonical asset rows.
7. Preserve existing lesson UX while migrating incrementally.
## Non-Goals (Initial Release)
- Replacing transcoding pipeline.
- Cross-organization sharing.
- Full billing implementation (we will provide usage metrics for billing).
## Proposed Architecture
### Data Model (New)
#### `assets`
Canonical asset record at organization scope.
Suggested fields:
- `id` (uuid, pk)
- `organization_id` (uuid, fk -> `organization.id`, indexed)
- `kind` (`video` | `document` | `image` | `audio` | `other`)
- `provider` (`upload` | `youtube` | `generic` | `external_url`)
- `storage_provider` (`s3` initially)
- `storage_key` (text, nullable, indexed)
- `source_url` (text, nullable)
- `mime_type` (text, nullable)
- `byte_size` (bigint/int, nullable for external assets, required for uploaded assets)
- `checksum` (text, nullable)
- `title` (text, nullable)
- `description` (text, nullable)
- `thumbnail_url` (text, nullable)
- `duration_seconds` (int, nullable)
- `aspect_ratio` (text, nullable)
- `is_external` (boolean, default false)
- `status` (`active` | `archived`)
- `created_by_profile_id` (uuid, fk -> `profile.id`)
- `created_at`, `updated_at`
Indexes/constraints:
- `(organization_id, created_at desc)`
- `(organization_id, kind, status)`
- `(organization_id, title)`
- `(organization_id, byte_size)`
- unique `(organization_id, provider, storage_key)` where `storage_key` is not null
#### `asset_usages`
Usage graph from asset to content targets.
Suggested fields:
- `id` (uuid, pk)
- `organization_id` (uuid, indexed)
- `asset_id` (uuid, fk -> `assets.id`, indexed)
- `target_type` (`lesson` | `exercise` | `question`)
- `target_id` (uuid)
- `slot_type` (`lesson_video` | `lesson_document` | `exercise_attachment` | `question_prompt_media` | `question_explanation_media` ...)
- `slot_key` (text, nullable; path-like key for nested content, e.g. `questions[2].prompt.media`)
- `position` (int, nullable)
- `created_by_profile_id` (uuid)
- `created_at`
Constraints:
- unique `(asset_id, target_type, target_id, slot_type, slot_key, position)`
- indexes `(target_type, target_id)` and `(asset_id)`
- service-layer scope validation: asset org must equal usage org.
### Storage Accounting
Storage metrics come from `assets`, not `asset_usages`.
Rules:
- Organization total: `sum(byte_size)` for `organization_id`.
- Reuse does not multiply storage.
- External assets (`is_external=true`) are excluded from internal storage totals.
Recommended outputs:
- `totalBytes`
- `bytesByKind`
- `internalBytes`
- `externalAssetCount`
### Backward Compatibility Strategy
- Keep `lesson.videos` during transition.
- Extend lesson media payload with `assetId?: string`.
- For new lesson attachments, write-through to both:
- canonical `assets` + `asset_usages`
- compatibility `lesson.videos`
- Read path should prefer canonical metadata when `assetId` exists.
### API Surface (Proposed)
#### Validation (`packages/utils/src/validation/assets/`)
- `ZAssetListQuery`
- `ZAssetCreateUpload`
- `ZAssetAttach`
- `ZAssetDetach`
- `ZAssetUpdate`
- `ZAssetDelete`
- `ZAssetUsageParams`
- `ZAssetExportQuery`
- `ZAssetStorageQuery`
#### DB Queries (`packages/db/src/queries/assets/`)
- `createAsset`
- `getAssetById`
- `listAssetsByOrg`
- `updateAsset`
- `archiveAsset`
- `deleteAsset`
- `createAssetUsage`
- `deleteAssetUsage`
- `listAssetUsagesByAsset`
- `countAssetUsagesByAsset`
- `listAssetsForExport`
- `getAssetStorageSummaryByOrg`
#### Services (`apps/api/src/services/assets/`)
- `createAssetFromUploadService`
- `attachAssetService`
- `detachAssetService`
- `updateAssetService`
- `deleteAssetService`
- `getAssetUsageGraphService`
- `exportOrganizationAssetsService`
- `getOrganizationAssetStorageService`
#### Routes (`apps/api/src/routes/assets/`)
All routes require `authMiddleware` + `orgTeamMemberMiddleware`, except delete/export where admin policy applies.
Proposed endpoints:
- `GET /organization/assets`
- `POST /organization/assets`
- `GET /organization/assets/:assetId`
- `PUT /organization/assets/:assetId`
- `DELETE /organization/assets/:assetId`
- `GET /organization/assets/:assetId/usage`
- `POST /organization/assets/:assetId/attach`
- `POST /organization/assets/:assetId/detach`
- `GET /organization/assets/export`
- `GET /organization/assets/storage`
## Frontend Plan
### Routes
- `apps/dashboard/src/routes/org/[slug]/media/+page.server.ts`
- `apps/dashboard/src/routes/org/[slug]/media/+page.svelte`
### Feature Module
- `apps/dashboard/src/lib/features/media-manager/`
- `api/media-manager.svelte.ts`
- `utils/types.ts`
- `components/media-library.svelte`
- `components/media-card.svelte`
- `components/media-usage-drawer.svelte`
- `components/edit-media-modal.svelte`
- `components/attach-media-modal.svelte`
- `components/storage-usage-card.svelte`
### Navigation
- Add item in `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`:
- title key: `org_navigation.media`
- path: `/media`
### Lesson and Exercise Integration
- Lesson upload completion:
1. upload file to storage via presign
2. create/get canonical asset with `kind='video'`
3. attach usage with `targetType='lesson'`, `targetId=lessonId`, `slotType='lesson_video'`
4. persist compatibility shape with `assetId` in `lesson.videos`
- Add “Choose from library” tab in lesson add-video modal.
- Add exercise-side attach flow using `targetType='exercise'` and slot metadata.
- Replace delete-by-index with detach behavior when asset has multiple usages.
## Export Design
- v1: JSON manifest + signed download URLs.
- Include:
- asset metadata (id, kind, provider, key, mimeType, byteSize, title, timestamps)
- usage entries (`targetType`, `targetId`, `slotType`, `slotKey`, `position`)
## Migration Plan
### Phase 0: Foundation (No UI changes)
1. Add `assets` and `asset_usages` tables + indexes.
2. Add assets queries/services/routes.
3. Add compatibility support for `assetId` in lesson video schema.
4. Add migration script to backfill existing lesson media payloads (videos + documents, internal + external).
### Phase 1: Org Media Manager + Lesson Reuse
1. Add org `/media` page.
2. Add list/search/filter/sort.
3. Add usage drawer.
4. Add attach-from-library flow in lesson editor.
### Phase 2: Exercise Reuse + Global Editing + Export + Storage
1. Add exercise usage attachments via `targetType='exercise'`.
2. Add global metadata editing.
3. Add export endpoint + UI action.
4. Add storage summary endpoint + UI card.
### Phase 3: Hardening
1. Deletion/archival policies.
2. Usage-safe deletion confirmations.
3. Pagination/performance tuning.
4. Reconciliation job for JSON/table drift.
## Migration Script Requirement
A dedicated script is required to migrate existing lesson media payloads into `assets` and `asset_usages`.
Script plan:
- File: `packages/db/src/scripts/backfill-lesson-assets.ts`
- Command: `pnpm --filter @cio/db db:assets-backfill`
- Reference spec: `prd/media-manager/backfill-lesson-videos-to-assets.md`
## Risks and Mitigations
- Drift between content JSON and canonical tables.
- Mitigation: write-through plus reconciliation report.
- Expired presigned URLs in existing `link` fields.
- Mitigation: key-based URL regeneration; key is source of truth.
- Shared asset update surprises.
- Mitigation: explicit UI copy that updates are global.
- Migration edge cases (missing keys, duplicate keys).
- Mitigation: idempotent script + dry-run + skip report.
- Missing historical file sizes.
- Mitigation: nullable `byte_size` + follow-up hydration job for unknown sizes.
## Success Metrics
- `% of upload videos with assetId >= 95%` within 2 weeks.
- Median attach-from-library time < 20s.
- Export success rate > 99%.
- Duplicate upload reduction >= 40%.
- `% of internal assets with non-null byte_size >= 98%`.
## Open Questions
1. Should we add size hydration for existing external assets where `byte_size` is unknown?
2. Should delete be admin-only in v1?
3. Should v1 use soft-delete only (`archived`) before hard-delete?
4. Should storage totals exclude archived assets?
5. What is the final controlled vocabulary for `slot_type` values across exercises/questions?
## Kanban Board (Ideation Phase)
| Ticket | Title | Todo | In Progress | Verification | Done |
| --- | --- | --- | --- | --- | --- |
| MM-1 | DB: create `assets` and `asset_usages` tables | [ ] | [ ] | [ ] | [ ] |
| MM-2 | Validation schemas for assets APIs | [ ] | [ ] | [ ] | [ ] |
| MM-3 | DB queries for assets CRUD + usage graph | [ ] | [ ] | [ ] | [ ] |
| MM-4 | Service layer for attach/detach/update/delete/export | [ ] | [ ] | [ ] | [ ] |
| MM-5 | API routes under organization scope (`/organization/assets`) | [ ] | [ ] | [ ] | [ ] |
| MM-6 | Backfill script: lesson media (videos + documents) -> assets + usages | [ ] | [ ] | [ ] | [ ] |
| MM-7 | Dashboard feature module (`media-manager`) | [ ] | [ ] | [ ] | [ ] |
| MM-8 | Org `/media` page with list/search/filter | [ ] | [ ] | [ ] | [ ] |
| MM-9 | Usage drawer with target/slot visibility | [ ] | [ ] | [ ] | [ ] |
| MM-10 | Lesson “Choose from library” attach flow | [ ] | [ ] | [ ] | [ ] |
| MM-11 | Exercise slot attach flow | [ ] | [ ] | [ ] | [ ] |
| MM-12 | Global media edit modal | [ ] | [ ] | [ ] | [ ] |
| MM-13 | Export all assets (manifest + signed URLs) | [ ] | [ ] | [ ] | [ ] |
| MM-14 | Storage summary endpoint + UI | [ ] | [ ] | [ ] | [ ] |
| MM-15 | Permission hardening + audit logs | [ ] | [ ] | [ ] | [ ] |
## Verification Checklist
- [ ] `pnpm --filter @cio/db build`
- [ ] `pnpm --filter @cio/utils build`
- [ ] `pnpm --filter @cio/api build`
- [ ] `pnpm --filter @cio/dashboard build`
- [ ] Backfill dry-run on staging snapshot
- [ ] Manual smoke: upload -> attach elsewhere -> edit metadata -> verify propagation
- [ ] Manual smoke: same asset used in multiple slots of one exercise
- [ ] Manual smoke: storage summary correctness against sample dataset
- [ ] Manual smoke: export output integrity
@@ -0,0 +1,143 @@
# Backfill Script Spec: Lesson Media -> Assets
## Goal
Migrate existing lesson media JSON (`lesson.videos` and `lesson.documents`) into canonical `assets` and `asset_usages`.
This backfill is **not video-only**. It handles:
- uploaded media (`provider='upload'`)
- external links (`youtube`, `generic`, `external_url`)
- inferred asset kinds (`video`, `document`, `image`, `audio`, `other`)
Usage rows are created with:
- `target_type='lesson'`
- `slot_type='lesson_video'` for `lesson.videos`
- `slot_type='lesson_document'` for `lesson.documents`
## Script Location
- `packages/db/src/scripts/backfill-lesson-assets.ts`
## Script Command
- `packages/db/package.json`
- `"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts"`
## Scope
Include lesson media where:
- `lesson.videos[]` entries can be normalized to known provider + key/link
- `lesson.documents[]` entries can be normalized to key/link
Skip and report records where:
- media item is malformed
- key/link requirements are missing
- media type/provider cannot be resolved
- lesson has missing org/course linkage
## Canonical Mapping
### From `lesson.videos[]`
- Provider mapping:
- `upload` -> `provider='upload'`
- `youtube` -> `provider='youtube'`
- `generic` -> `provider='generic'`
- `storage_key` from `video.key` for uploaded items
- `source_url` from `video.link`
- `mime_type` inferred from metadata or filename/url
- `kind` inferred from `mime_type`; fallback to `video`
### From `lesson.documents[]`
- Provider mapping:
- with `key` -> `provider='upload'`
- without `key` and with link -> `provider='external_url'`
- `storage_key` from `document.key`
- `source_url` from `document.link`
- `mime_type` inferred from explicit type, metadata, filename, or url extension
- `kind` inferred from `mime_type`; fallback to `document`
### Common Fields
For each created/reused asset:
- `organization_id`
- `kind`
- `provider`
- `storage_provider` (`s3` for upload, `external` for external)
- `storage_key`
- `source_url`
- `mime_type`
- `byte_size`
- `title`
- `description`
- `thumbnail_url`
- `duration_seconds`
- `aspect_ratio`
- `is_external`
- `status='active'`
- `metadata`
## Idempotency Rules
1. Reuse by existing `assetId` first (if valid for org + natural key match).
2. Fallback natural-key reuse:
- upload: `(organization_id, provider='upload', storage_key)`
- external: `(organization_id, provider, source_url, kind, is_external=true)`
3. Usage uniqueness:
- `(asset_id, target_type, target_id, slot_type, slot_key, position)`
4. Re-running script must not create duplicate assets/usages.
5. Lesson payload patch only updates `assetId` when missing or stale.
## Dry-Run Mode
- Default behavior is dry-run unless `--execute` is provided.
- Dry-run performs reads and decisioning but does not write.
- Outputs summary counters and sample rows.
## Write Mode Behavior
For each qualifying lesson media item:
1. Resolve organization id from lesson -> course -> group.
2. Resolve or create canonical asset.
3. Resolve or create canonical usage row with lesson target and slot metadata.
4. Patch lesson JSON item with canonical `assetId`.
Patch behavior:
- preserve existing content payload shape
- only mutate `assetId` field where needed
## Handling Missing Historical Size
Historical rows may not include file size.
Policy:
- Write `byte_size=null` when unknown.
- Track `assetsMissingByteSize` in summary output.
- Follow up later with optional size hydration job.
## Performance
- Batch lessons (`limit/offset` with configurable batch size).
- Use per-lesson transaction in execute mode.
- Log progress every processed batch.
## Suggested Output Summary
- `lessonsScanned`
- `mediaItemsScanned`
- `mediaItemsEligible`
- `assetsCreated`
- `assetsReused`
- `usagesCreated`
- `usagesReused`
- `lessonItemsPatchedWithAssetId`
- `assetsWithByteSize`
- `assetsMissingByteSize`
- `skippedMalformed`
- `skippedMissingKey`
- `skippedMissingLink`
- `skippedUnsupportedType`
- `errors`
## Safety
- Default dry-run.
- Require `--execute` for writes.
- Exit non-zero on unhandled failures.
## Verification
1. Run dry-run on staging snapshot and inspect summary/sample rows.
2. Run execute mode.
3. Validate:
- expected assets/usages were created or reused
- no uniqueness regressions
- `assetId` patching present in lesson payloads
- supported kinds include non-video where applicable
4. Re-run execute mode and confirm near-zero new writes.
+247
View File
@@ -0,0 +1,247 @@
# Media Storage Decoupling + MinIO Support PRD
## Purpose
Decouple ClassroomIO media storage from Cloudflare-specific infrastructure so open-source and self-hosted users can control their own media storage backend, with first-class MinIO support.
## Problem Statement
The current media pipeline is tightly coupled to Cloudflare R2 and Cloudflare Browser Rendering APIs. This creates friction for open-source adopters who want full infrastructure ownership, local/private deployments, and non-Cloudflare object storage.
Current tight-coupling points:
- `apps/api/src/utils/s3.ts` hardcodes Cloudflare R2 endpoint and credentials.
- `apps/api/src/services/media.ts` requires `CLOUDFLARE_IMAGE_BUCKET_DOMAIN` for image URLs.
- `apps/api/src/routes/course/presign.ts` directly uses Cloudflare-bound storage utilities.
- `apps/api/src/utils/lesson-media.ts` directly generates download URLs using Cloudflare-bound utilities.
- `apps/api/src/config/env.ts` and `apps/api/src/constants/index.ts` are Cloudflare-first.
- Docker and docs surface only `CLOUDFLARE_*` storage setup paths.
## Goals
1. Introduce provider-based object storage architecture in API code.
2. Preserve current dashboard and API contracts for uploads/downloads.
3. Support Cloudflare R2 through a generic S3-compatible provider.
4. Add MinIO as a required, documented, self-hosted storage option.
5. Keep backward compatibility for existing Cloudflare-based deployments.
6. Maintain canonical asset metadata and usage tracking without regressions.
## Non-Goals (Initial Release)
- Migrating away from presigned URL upload/download flows.
- Replacing current video transcoding strategy.
- Fully decoupling PDF generation from Cloudflare in the same release.
- Per-organization storage-provider selection (initial scope is instance-level).
## Requirements
### Functional Requirements
1. API storage access must go through a provider abstraction layer, not Cloudflare-specific helpers.
2. Existing presign API endpoints and response shapes must remain unchanged.
3. Existing media image upload endpoint response shape must remain unchanged.
4. Download URL generation for lessons/documents must remain transparent to frontend consumers.
5. Existing Cloudflare deployments must continue working with no required immediate migration.
### MinIO Requirements (Must-Have)
1. MinIO support is required in the first release of this decoupling effort.
2. MinIO must work with direct browser uploads via presigned URLs.
3. MinIO must support three buckets equivalent to current model:
- `videos`
- `documents`
- `media`
4. MinIO path-style compatibility must be supported.
5. Docker self-host flow must include an option to run MinIO locally.
6. Self-host docs must include complete MinIO setup steps:
- bucket creation
- credentials
- CORS rules
- required env vars
- verification steps
### Backward Compatibility Requirements
1. If new storage env vars are absent and `CLOUDFLARE_*` vars are present, runtime must auto-map to Cloudflare-compatible mode.
2. Existing frontend flows must not require code changes for basic upload/download behavior.
3. Existing `storageProvider` values in assets should continue to validate and persist.
### Non-Functional Requirements
1. No material latency regression for presign endpoints.
2. Error messages should be provider-agnostic and actionable.
3. Security posture must remain consistent:
- short-lived presigned URLs
- content-type restrictions
- file-size validation
- principle of least privilege for storage credentials
## Current State Audit
| Area | Current State | Gap | Priority |
| --- | --- | --- | --- |
| Object storage client | Cloudflare R2 hardcoded | No provider abstraction | P0 |
| Presigned URLs | Cloudflare-bound utility | Cannot swap provider | P0 |
| Image upload public URL | Cloudflare domain env only | No generic public URL strategy | P0 |
| Env config | `CLOUDFLARE_*` only | No generic object storage config | P0 |
| Docker self-host | No MinIO service path | Hard for OSS users to self-own media | P0 |
| Docs | Cloudflare-first guidance | Missing MinIO-first self-host path | P0 |
| Asset uniqueness constraint | Unique key excludes `storageProvider` | Potential collision if multiple providers introduced later | P1 |
| PDF rendering | Cloudflare-specific browser rendering API | Separate Cloudflare dependency remains | P2 |
## Proposed Architecture
### 1) Storage Provider Abstraction
Create a provider interface under API utils/services layer.
Suggested interface responsibilities:
- `uploadObject`
- `getObject`
- `deleteObject`
- `createUploadPresignedUrl`
- `createDownloadPresignedUrl`
- `buildPublicUrl`
### 2) S3-Compatible Provider
Implement a single S3-compatible provider using AWS SDK v3, configured by env.
This provider should support:
- Cloudflare R2
- MinIO
- AWS S3
- Other S3-compatible object stores
### 3) Config Model
Add provider-neutral env vars in API config:
- `OBJECT_STORAGE_DRIVER` (initial values: `s3-compatible`)
- `OBJECT_STORAGE_REGION`
- `OBJECT_STORAGE_ENDPOINT`
- `OBJECT_STORAGE_PUBLIC_ENDPOINT` (optional; used for browser-facing presigned URLs where needed)
- `OBJECT_STORAGE_ACCESS_KEY_ID`
- `OBJECT_STORAGE_SECRET_ACCESS_KEY`
- `OBJECT_STORAGE_FORCE_PATH_STYLE` (important for MinIO compatibility)
- `OBJECT_STORAGE_BUCKET_VIDEOS`
- `OBJECT_STORAGE_BUCKET_DOCUMENTS`
- `OBJECT_STORAGE_BUCKET_MEDIA`
- `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`
- `OBJECT_STORAGE_PRESIGN_UPLOAD_EXPIRES_SECONDS`
- `OBJECT_STORAGE_PRESIGN_DOWNLOAD_EXPIRES_SECONDS`
Backward-compatible fallback mapping from existing vars:
- `CLOUDFLARE_ACCOUNT_ID`
- `CLOUDFLARE_ACCESS_KEY`
- `CLOUDFLARE_SECRET_ACCESS_KEY`
- `CLOUDFLARE_BUCKET_DOMAIN`
- `CLOUDFLARE_IMAGE_BUCKET_DOMAIN`
### 4) API Contract Stability
Keep these contracts unchanged:
- `POST /course/presign/video/upload`
- `POST /course/presign/document/upload`
- `POST /course/presign/video/download`
- `POST /course/presign/document/download`
- `POST /media/image`
### 5) Data Model Considerations
Current assets schema default `storage_provider='s3'` is acceptable for initial rollout.
If multi-provider-per-instance or migration coexistence is introduced, consider updating unique constraint:
- from `(organization_id, provider, storage_key)`
- to `(organization_id, provider, storage_provider, storage_key)`
## Complexity Assessment
| Workstream | Complexity | Why |
| --- | --- | --- |
| Storage abstraction refactor (`apps/api`) | High | Core upload/download paths and presign generation are central and cross-cutting |
| MinIO support in presign/browser flows | High | Requires endpoint/path-style/CORS correctness across API and browser contexts |
| Env/config migration with backward compatibility | Medium | Needs careful fallback behavior and clear deprecation strategy |
| Docker self-host MinIO integration | Medium | Additional service wiring, bucket bootstrap, and docs updates |
| Dashboard compatibility | Low | Existing frontend contracts can remain unchanged if API contracts are preserved |
| DB uniqueness hardening for future multi-provider | Medium | Requires migration planning and index/constraint rollout |
| Testing and regression coverage | High | Upload/download flows span API, browser, storage service, and content rendering paths |
| PDF rendering decoupling | Out of scope in this release | Separate dependency track and broader decision surface |
## Implementation Plan
### Phase 0: Foundation
1. Introduce provider interface and s3-compatible provider implementation.
2. Replace direct imports from Cloudflare-bound storage utility across:
- `apps/api/src/services/media.ts`
- `apps/api/src/routes/course/presign.ts`
- `apps/api/src/utils/lesson-media.ts`
3. Implement provider-neutral config in env schema and constants.
4. Add Cloudflare fallback mapping and warning logs.
### Phase 1: MinIO Required Path
1. Add MinIO configuration support in API.
2. Add/extend Docker compose for optional MinIO service.
3. Add bucket bootstrap instructions (videos/documents/media).
4. Add CORS guidance specific to browser presigned PUT/GET/HEAD behavior.
### Phase 2: Documentation + Hardening
1. Update self-host docs with MinIO-first workflow.
2. Update Cloudflare docs to show Cloudflare as one provider option.
3. Update `apps/api/.env.example` and docker env templates.
4. Add integration smoke tests for upload/download and media image URL generation.
### Phase 3: Optional Follow-Up
1. Evaluate schema unique constraint expansion to include `storage_provider`.
2. Evaluate PDF rendering provider abstraction (Cloudflare-independent renderer).
## Required File Touchpoints
Core API:
- `apps/api/src/config/env.ts`
- `apps/api/src/constants/index.ts`
- `apps/api/src/utils/s3.ts` (or replacement provider module)
- `apps/api/src/services/media.ts`
- `apps/api/src/routes/course/presign.ts`
- `apps/api/src/utils/lesson-media.ts`
Docs and self-host:
- `apps/docs/content/docs/quickstart/self-hosting.mdx`
- `apps/docs/content/docs/contributor-guides/cloudflare-setup.mdx`
- `apps/api/.env.example`
- `docker/docker-compose.yaml`
- `docker/docker-compose.prod.yaml`
- `docker/.env.prod.example`
- `docker/docs/USAGE.md`
- `docker/docs/SELF_HOST.md`
Optional DB follow-up:
- `packages/db/src/schema.ts`
- corresponding migration files
## Testing Strategy
### Unit Tests
1. Provider config parsing and fallback behavior.
2. Presigned URL generation for upload/download.
3. Public URL generation for media bucket objects.
### Integration Tests
1. Presign video upload endpoint returns valid URL + key.
2. Presign document upload endpoint returns valid URL + key.
3. Presign download endpoints return URL map for provided keys.
4. Media image upload endpoint uploads and returns public URL.
### Manual Verification Matrix
1. Cloudflare R2 existing env-only setup remains functional.
2. MinIO self-host setup works end-to-end for:
- video upload
- document upload
- thumbnail/image upload
- lesson playback/download link resolution
## Risks and Mitigations
| Risk | Impact | Mitigation |
| --- | --- | --- |
| Presigned URL host mismatch in containerized MinIO setups | Upload/download failures in browser | Support public endpoint config and document deployment topology clearly |
| CORS misconfiguration in MinIO buckets | Browser request failures | Provide explicit CORS examples and startup checklist |
| Hidden reliance on Cloudflare-specific behavior | Runtime regressions | Add compatibility tests and phased rollout with fallback |
| Config ambiguity between old and new env vars | Misconfiguration risk | Define clear precedence, validation, and startup logs |
| Future multi-provider collisions in assets uniqueness | Data integrity risk | Plan optional schema hardening phase with migration |
## Acceptance Criteria
1. A self-hosted deployment can use MinIO without any Cloudflare credentials.
2. Existing Cloudflare deployments continue to work without immediate config rewrite.
3. Dashboard upload/playback/download behavior remains unchanged for users.
4. Docs provide a complete MinIO setup path from zero to working upload/playback.
5. Docker self-host users can run a documented MinIO-backed stack.
## Open Questions
1. Should we support two endpoints in config by default (`internal` for API operations, `public` for presigned browser URLs), or require one endpoint reachable by both?
2. Do we want to enforce a strict enum for `storageProvider` in validation now, or keep it flexible string-based for compatibility?
3. Should PDF rendering decoupling be planned as a separate PRD immediately after this release?
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
# Onboarding AI Website Bootstrap PRD
## Status
- Draft
## Date
- February 19, 2026
## Purpose
Redesign onboarding so a new creator can paste their website URL, get two AI-generated starter courses about their product, and have organization branding/personalization applied automatically.
## Problem Statement
Current onboarding collects profile + org metadata, then lands users in an empty workspace. New users still need to:
- manually define course structure
- manually pick brand/theme settings
- manually decide first setup actions
This creates a high time-to-first-value and lower activation for creators who already have product content on their website.
## ICP (Primary)
1. SaaS founders who want to launch an academy for their product.
2. Businesses that want to help customers understand and adopt their offering.
3. Operational teams in hospitals, hotels, and restaurants that need internal compliance training for staff.
## Target Jobs-To-Be-Done
1. Turn existing website/product content into structured training quickly.
2. Launch a branded learning experience without manual setup from scratch.
3. Deliver onboarding, enablement, and compliance training with minimal content ops overhead.
## Persona-Specific Onboarding Paths (v1)
### Path A: SaaS Academy
Default generated course pair:
1. `Product Foundations` (core concepts, setup, first value)
2. `Advanced Workflows by Role` (admin/user role playbooks + best practices)
Default suggestions:
- Add role-based tags (`admin`, `manager`, `end-user`)
- Add “Getting Started” landing-page CTA
- Invite internal CS/support as tutors
### Path B: Customer Enablement
Default generated course pair:
1. `Customer Onboarding Essentials` (what to do in week 1)
2. `Value Expansion and Advanced Features` (adoption and retention playbooks)
Default suggestions:
- Add “New Customer” and “Power User” tracks
- Add recommended completion timeline
- Enable course update/newsfeed cadence for release education
### Path C: Compliance Training (Hospital / Hotel / Restaurant)
Default generated course pair:
1. `Mandatory Compliance Fundamentals` (policy, safety, conduct)
2. `Role-Based SOP and Incident Handling` (scenario-based operational compliance)
Default suggestions:
- Mark courses as compliance-required (draft policy metadata for v1)
- Enable completion/certificate checklist prompts
- Recommend recurring review cadence (for example quarterly refresher)
## Goals
1. Capture a website URL during onboarding and extract product + brand context.
2. Generate exactly two starter courses during onboarding.
3. Personalize the workspace by setting org theme color from detected brand color.
4. Suggest/apply light or dark appearance preference for the onboarding user.
5. Suggest additional setup actions based on extracted context and land users in a ready-to-edit org.
## Non-Goals (v1)
- Fully autonomous publishing (courses remain draft/unpublished).
- Deep multi-domain crawling or full docs migration.
- Perfect brand identity replication.
- Replacing long-form course import providers (Teachable/Thinkific/SCORM/CSV).
## Confirmed Product Decisions
1. Onboarding asks for the user website.
2. AI generates two courses as part of onboarding.
3. Theme personalization is included (dark/light + auto-picked org color).
4. Onboarding should include additional setup suggestions beyond courses and color.
## Data Sources Checked
- `apps/dashboard/src/routes/(auth)/onboarding/+page.svelte`
- `apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts`
- `apps/dashboard/src/lib/features/onboarding/utils/constants.ts`
- `apps/api/src/routes/onboarding/onboarding.ts`
- `apps/api/src/services/onboarding.ts`
- `packages/utils/src/validation/onboarding/onboarding.ts`
- `apps/dashboard/src/lib/features/settings/pages/org.svelte`
- `apps/dashboard/src/lib/utils/functions/theme.ts`
- `apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte`
- `apps/api/src/routes/organization/organization.ts`
- `packages/utils/src/validation/organization/organization.ts`
- `packages/db/src/schema.ts`
- `apps/api/src/routes/course/course.ts`
- `apps/api/src/services/course/course.ts`
- `prd/course-import/implementation-plan.md`
## Current Baseline (As-Is)
1. Onboarding is a 2-step flow (step 1: fullname + orgName + siteName, step 2: goal + source + locale).
2. No course creation happens during onboarding.
3. Org theme supports named values and custom hex (`organization.theme`), and can be updated today.
4. Dark/light mode is currently user preference in local UI mode state, not part of onboarding data.
## Proposed Onboarding V2 Experience
### Step 1: Account + Org Basics
Collect or confirm:
- fullname
- organization name
- siteName
- locale
### Step 2: Website Input
Collect:
- primary website URL (required)
- optional product page/docs URL override
- optional target audience prompt (for better AI output)
- optional onboarding path selector (`saas_academy`, `customer_enablement`, `compliance_training`)
### Step 3: AI Analysis
System runs async website analysis:
- crawl same-domain pages (bounded depth/page limit)
- extract product summary, ICP hints, feature areas
- detect brand assets (logo/favicon/theme color)
- infer recommended onboarding path if user did not explicitly choose one
### Step 4: Course Blueprint Review
Show two generated course blueprints:
1. Course A: Product Fundamentals
2. Course B: Advanced Use Cases / Role-based Workflows
Each course includes:
- title
- description
- default section
- 4-6 lesson outlines
- 1 starter exercise draft
User actions:
- edit titles/descriptions
- regenerate one course or both
- remove/add lesson outline items
- switch onboarding path template and re-generate course pair
### Step 5: Personalization Review
Show and allow override:
- detected primary brand color (mapped to `organization.theme`)
- appearance preference (`light`, `dark`, or `system`)
- detected logo/avatar suggestion
Also show onboarding suggestions:
- suggested landing page headline/subheadline
- suggested course tags
- suggested first invite actions (team members, first students)
### Step 6: Apply + Finish
On confirm:
- create org (if not already created in flow)
- persist theme and optional avatar
- create the two draft courses with starter content
- store onboarding metadata and suggestions
- redirect to org dashboard with a “generated setup” success state
## Functional Requirements
### FR-1 Website Analysis Input
- Validate and normalize URL.
- Restrict crawling to HTTP/HTTPS and public domains only.
- Protect against SSRF/local-network targets.
- Enforce crawl limits (page count, timeout, response size).
### FR-2 Brand Extraction
- Derive brand color with fallback priority:
1. explicit CSS/theme token
2. `<meta name="theme-color">`
3. dominant logo/favicon color
4. fallback (`blue`)
- Validate color contrast; if unsafe, auto-adjust and flag it in preview.
### FR-3 AI Course Blueprint Generation
- Generate exactly two course blueprints.
- Return structured output only (schema-validated JSON).
- Include source traceability (which pages influenced which course sections).
- Mark all generated courses as draft/unpublished.
### FR-4 Human-in-the-loop Review
- Users must approve before final apply.
- Users can regenerate and edit blueprint fields in onboarding UI.
- “Skip AI” path should remain available and continue with basic onboarding.
### FR-5 Persisted Personalization
- Set `organization.theme` to selected/detected theme value.
- Save appearance preference for current user (proposed `profile.metadata.appearanceMode`).
- Apply appearance and theme immediately in client after onboarding completion.
### FR-6 Starter Course Creation
- Use existing course service layer to create course shells.
- Create default section + lessons + exercise stubs through current services/routes.
- Keep generation idempotent for retries (no duplicate courses on accidental resubmit).
### FR-7 Setup Suggestions
Post-onboarding suggestions should include:
- publish checklist for generated courses
- customization recommendations (logo, landing page, custom domain)
- invitation recommendations (team setup + learner onboarding)
### FR-8 Observability + Analytics
- Track funnel events: `onboarding_v2_started`, `website_analysis_completed`, `blueprint_regenerated`,
`onboarding_v2_applied`, `onboarding_v2_skipped_ai`.
- Track time-to-first-course-created and time-to-first-publish.
### FR-9 Persona Routing Rules
- Respect explicit user-selected onboarding path over inferred path.
- If no explicit path is selected, infer path from website analysis with confidence score.
- If inference confidence is low, default to `customer_enablement` and ask user to confirm.
- Store chosen path in onboarding metadata for post-onboarding recommendations.
## Proposed API + Service Design
### Validation (`packages/utils/src/validation/onboarding/`)
- `ZOnboardingBootstrapStart`
- `ZOnboardingBootstrapStatusParam`
- `ZOnboardingBootstrapApply`
- `ZOnboardingBootstrapRegenerateCourse`
### Routes (`apps/api/src/routes/onboarding/`)
- `POST /onboarding/bootstrap/start`
- `GET /onboarding/bootstrap/:jobId`
- `POST /onboarding/bootstrap/:jobId/regenerate-course`
- `POST /onboarding/bootstrap/:jobId/apply`
### Services (`apps/api/src/services/`)
- `onboarding-bootstrap.ts` for create/run bootstrap jobs, website extraction orchestration, AI blueprint generation,
and apply workflow (org theme + course creation + metadata persistence).
### Dashboard (`apps/dashboard/src/lib/features/onboarding/`)
- add onboarding v2 API class + step components
- show async analysis status and blueprint review UI
- call apply endpoint and handle redirect
## Data Model Changes (Proposed)
### New Table: `onboarding_bootstrap_job`
Fields:
- `id`
- `profileId`
- `organizationId` (nullable until org exists)
- `websiteUrl`
- `status` (`PENDING` | `RUNNING` | `COMPLETED` | `FAILED` | `CANCELED`)
- `progressPercent`
- `stage`
- `extraction` (jsonb)
- `blueprint` (jsonb)
- `suggestions` (jsonb)
- `error` (jsonb)
- timestamps
### Profile metadata extension
- `profile.metadata.onboarding.appearanceMode`
- `profile.metadata.onboarding.websiteUrl`
- `profile.metadata.onboarding.bootstrapJobId`
## Security and Quality Constraints
1. No crawling of private IP ranges or localhost targets.
2. Strip scripts and unsafe HTML before sending page content to LLM.
3. Limit token/page budgets to protect cost.
4. Do not auto-publish generated courses.
5. Preserve source links for user trust and editability.
## Rollout Plan
1. Phase 1: Internal alpha behind feature flag (`onboarding_v2_ai_bootstrap`).
2. Phase 2: 10-20% cloud rollout for new signups.
3. Phase 3: Default onboarding for cloud; legacy flow remains fallback.
4. Phase 4: Evaluate self-hosted behavior (likely disabled by default unless keys configured).
## Success Metrics
1. +25% improvement in onboarding completion rate.
2. +40% improvement in “first course created within first session”.
3. Median onboarding time under 8 minutes.
4. >60% of onboarding_v2 users keep at least one generated course.
5. <5% failure rate for website analysis jobs.
## Risks and Mitigations
1. Risk: hallucinated or low-quality course outlines. Mitigation: strict JSON schema + source-backed generation + review step.
2. Risk: wrong brand color extraction. Mitigation: preview + manual override + contrast guard.
3. Risk: long-running or stuck jobs. Mitigation: async job model with stage/progress, retries, and cancellation.
4. Risk: duplicate course creation on retries. Mitigation: idempotency keys and apply-step deduplication.
## Open Questions
1. Should “Apply” immediately create both courses, or should users choose one/both?
2. What is the default lesson/exercise depth per generated course for v1?
3. Should appearance preference remain user-only or introduce org-level default appearance?
4. Should self-hosted instances get this flow if they provide their own LLM key?
5. Which model/provider should power generation in v1 and what is per-user cost budget?
+114
View File
@@ -0,0 +1,114 @@
# Pricing Improvements PRD
## Status
- Draft
## Date
- February 17, 2026
## Purpose
Define a single, extensible pricing framework for ClassroomIO so current and future features can be clearly assigned to `BASIC`, `EARLY_ADOPTER`, or `ENTERPRISE` plans.
## Problem Statement
Plan definitions currently exist in multiple places (marketing plan copy, app UI gates, and plan constants). Some gates are only `free vs paid`, while product pricing has three tiers. We need one source of truth for feature entitlements so we can add features later without ambiguity.
## Goals
1. Keep one canonical plan-to-feature matrix for product and engineering.
2. Reflect the current in-app pricing behavior.
3. Add a future-feature allocation table so new capabilities can be assigned to plans consistently.
4. Explicitly classify Enterprise-only features (including SSO).
## Non-Goals
- Rebuilding billing provider integrations in this phase.
- Redesigning public pricing page copy in this phase.
- Shipping all entitlement backend checks in one release.
## Confirmed Decisions
1. Plan model remains `BASIC`, `EARLY_ADOPTER`, `ENTERPRISE`.
2. SSO is an Enterprise-only feature.
3. For assessment roadmap: code execution is out of scope.
4. For assessment roadmap: file upload limits are `2MB` (Basic/Free) and `10MB` (Paid plans).
## Data Sources Checked
- `packages/utils/src/plans/data.json`
- `packages/utils/src/plans/constants.ts`
- `apps/website/src/routes/pricing/+page.svelte`
- `apps/dashboard/src/lib/utils/store/org.ts`
- `apps/dashboard/src/lib/features/settings/pages/teams.svelte`
- `apps/dashboard/src/lib/features/settings/pages/domains.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte`
- `apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte`
- `apps/dashboard/src/lib/features/course/components/ceritficate/design.svelte`
- `apps/dashboard/src/lib/features/course/pages/settings.svelte`
- `apps/dashboard/src/lib/features/course/components/people/share-qr-image.svelte`
- `apps/dashboard/src/lib/features/ui/upgrade-powered-by.svelte`
- `apps/dashboard/src/lib/features/audience/pages/audience.svelte`
- `apps/dashboard/src/routes/org/[slug]/audience/+page.svelte`
- `prd/enterprise-sso/README.md`
## Plan Definitions in App
| Plan Key | Display Name | Pricing Source |
| --- | --- | --- |
| `BASIC` | Free / Basic | `packages/utils/src/plans/data.json` |
| `EARLY_ADOPTER` | Early Adopters | `packages/utils/src/plans/data.json` |
| `ENTERPRISE` | Enterprise | `packages/utils/src/plans/data.json` |
## Current In-App Entitlement Behavior
| Capability | BASIC | EARLY_ADOPTER | ENTERPRISE | Status |
| --- | --- | --- | --- | --- |
| Audience capacity | 20 | 10,000 | Unlimited | Live (`currentOrgMaxAudience`) |
| Team member invite/remove | No | Yes | Yes | Live (gated by `isFreePlan`) |
| Lesson video upload tab | No | Yes | Yes | Live (gated by `isFreePlan`) |
| Lesson document upload | No | Yes | Yes | Live (gated by `isFreePlan`) |
| Certificate customization | No | Yes | Yes | Live (gated by `isFreePlan`) |
| Lesson download toggle | No | Yes | Yes | Live (gated by `isFreePlan`) |
| Course download action | No | Yes | Yes | Live (gated by `isFreePlan`) |
| Powered-by branding visibility | Required | Hidden | Hidden | Live (free-only rendering) |
| QR invite ClassroomIO branding | Required | Hidden | Hidden | Live (free-only rendering) |
| Custom domain management | No | Yes (current UI behavior) | Yes | Live (currently `isFreePlan` gate) |
## Enterprise-Only Features
| Feature | BASIC | EARLY_ADOPTER | ENTERPRISE | Status |
| --- | --- | --- | --- | --- |
| SSO (SAML/OIDC) | No | No | Yes | Planned (`prd/enterprise-sso/README.md`) |
| SCIM lifecycle provisioning | No | No | Yes | Planned (phase after SSO) |
## Future Feature Allocation Backlog
| Feature Candidate | BASIC | EARLY_ADOPTER | ENTERPRISE | Status | Notes |
| --- | --- | --- | --- | --- | --- |
| Advanced assessment types (matching, ordering, audio/video, hotspot) | No | Yes | Yes | Proposed | From exercise question-types roadmap |
| Assessment file upload limit | 2MB | 10MB | 10MB | Proposed | Applies to assignment/test file submissions |
| Advanced assessment analytics (item analysis) | No | Yes | Yes | Proposed | Basic keeps completion + score only |
| Exercise-level resubmission controls | Yes | Yes | Yes | Proposed | Policy is by exercise, not by question type |
## Gaps To Fix (Pricing Improvements)
1. Move from broad `isFreePlan` checks to explicit feature entitlements per plan.
2. Align in-app enforcement with published plan matrix where they differ.
3. Add backend entitlement checks for all gated actions (not UI-only checks).
4. Add a central entitlement API used by dashboard and API services.
## Functional Requirements
1. Create a canonical entitlement registry keyed by plan and feature code.
2. Add `entitlement` checks in backend routes/services for gated actions.
3. Keep frontend checks for UX, but backend remains source of truth.
4. Return clear upgrade-required errors with plan context.
5. Preserve behavior for existing orgs during rollout.
## Rollout Plan
1. Phase 1: Normalize feature registry and map all current gates.
2. Phase 2: Add backend enforcement for current gated features.
3. Phase 3: Introduce new feature allocations (assessment roadmap, SSO gating, future additions).
## Success Metrics
- 0 critical mismatches between pricing page promises and app enforcement.
- Reduction in support tickets about "why this feature is locked/unlocked".
- Faster launch cycle for pricing new features (single matrix update + entitlement key).
## Open Questions
- Should custom domain remain available to all paid plans or become Enterprise-only in enforcement?
- Which future features should be `EARLY_ADOPTER+` versus strictly `ENTERPRISE`?
- Do we need plan-level audit reporting for entitlement failures?
+365
View File
@@ -0,0 +1,365 @@
---
name: Public API with Keys
overview: Build a versioned public API (v1) authenticated via organization-scoped API keys, with course CRUD, course export, student/audience CRUD endpoints, plus a dashboard Automation page with API key management UI.
todos:
- id: db-schema
content: Add `api_key` table to schema.ts, update relations, generate migration
status: pending
- id: db-queries
content: Create api-key query functions (create, getByHash, getByOrg, revoke, updateLastUsed)
status: pending
- id: api-key-middleware
content: Build apiKeyMiddleware that authenticates via Bearer token and sets orgId in context
status: pending
- id: validation-schemas
content: Add Zod schemas for API key management and public API input validation
status: pending
- id: api-key-service
content: Create api-key service (key generation, hashing, CRUD)
status: pending
- id: api-key-routes
content: Create session-authenticated API key management routes (POST/GET/DELETE /api-key)
status: pending
- id: v1-course-service
content: Create v1 course service (list, get, create, update, delete, export, students)
status: pending
- id: v1-audience-service
content: Create v1 audience service (list, get, add, update, remove)
status: pending
- id: v1-routes
content: Create versioned v1 router with course and audience sub-routers, register in app.ts
status: pending
- id: tests
content: Write tests for api-key, v1/courses, v1/audience endpoints using Hono app.request()
status: pending
- id: translations
content: Add automation-related translation keys to en.json
status: pending
- id: sidebar-nav
content: Add Automation nav item to org-navigation.ts
status: pending
- id: automation-page
content: Create automation page with Tabs (API/Zapier/Webhooks), free plan gate, API key display
status: pending
- id: automation-api-class
content: Create frontend API class for api-key management
status: pending
- id: build-verify
content: Build API package, verify no lint errors, run tests
status: pending
- id: openapi-contracts
content: Ensure all new public API routes are included in OpenAPI generation (route metadata + schemas)
status: pending
- id: openapi-publish
content: Regenerate and upload latest OpenAPI spec via `upload-openapi-spec.ts`
status: pending
- id: docs-openapi-integration
content: Integrate OpenAPI docs page in `apps/docs` using Fumadocs OpenAPI integration
status: pending
- id: docs-openapi-nav
content: Add `API` docs navigation entry/page in `apps/docs/content/docs/meta.json`
status: pending
isProject: false
---
# Public API with Organization-Scoped API Keys
## Architecture Overview
```mermaid
flowchart TB
subgraph client [External Client]
APIKey["API Key in Authorization header"]
end
subgraph api [API Server]
V1Router["/v1 Router"]
APIKeyMiddleware["apiKeyMiddleware"]
CourseRoutes["Course Routes"]
AudienceRoutes["Audience Routes"]
APIKeyRoutes["API Key Management"]
end
subgraph db [Database]
APIKeyTable["api_key table"]
OrgTable["organization"]
CourseTable["course / group"]
AudienceTable["groupmember / profile"]
end
client -->|"Bearer cio_..."| V1Router
V1Router --> APIKeyMiddleware
APIKeyMiddleware -->|"validates + resolves orgId"| APIKeyTable
APIKeyMiddleware --> CourseRoutes
APIKeyMiddleware --> AudienceRoutes
APIKeyRoutes -->|"session auth"| APIKeyTable
```
## 1. Database: `api_key` Table
Add a new table in [packages/db/src/schema.ts](packages/db/src/schema.ts):
```typescript
export const apiKey = pgTable('api_key', {
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
keyHash: text('key_hash').notNull(), // SHA-256 hash of the key
keyPrefix: varchar('key_prefix', { length: 12 }).notNull(), // "cio_live_xxxx" for display
name: varchar('name', { length: 255 }).notNull().default('Default'),
lastUsedAt: timestamp('last_used_at'),
expiresAt: timestamp('expires_at'),
isRevoked: boolean('is_revoked').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
createdBy: uuid('created_by').notNull().references(() => profile.id),
});
```
- The raw key is only shown once on creation; we store a SHA-256 hash.
- `keyPrefix` (e.g. `cio_live_a1b2`) lets users identify keys in the UI without exposing the secret.
- DON'T GENERATE MIGRATION, when we run `pnpm db push` it will automatically apply the changes.
## 2. API Key Queries
New file: [packages/db/src/queries/api-key/api-key.ts](packages/db/src/queries/api-key/api-key.ts)
Functions:
- `createApiKey(orgId, createdBy, name?)` -- inserts row, returns id
- `getApiKeyByHash(hash)` -- lookup for auth middleware
- `getApiKeysByOrganization(orgId)` -- list keys (prefix, name, createdAt, lastUsedAt, isRevoked)
- `revokeApiKey(keyId, orgId)` -- sets `isRevoked = true`
- `updateApiKeyLastUsed(keyId)` -- stamps `lastUsedAt`
## 3. API Key Auth Middleware
New file: [apps/api/src/middlewares/api-key.ts](apps/api/src/middlewares/api-key.ts)
- Reads `Authorization: Bearer cio_live_...` header
- SHA-256 hashes the key, looks up via `getApiKeyByHash`
- Checks `isRevoked`, `expiresAt`
- Sets `c.set('orgId', key.organizationId)` on context (same pattern as `orgMemberMiddleware`)
- Returns 401 on invalid/expired/revoked key
## 4. Validation Schemas
New file: [packages/utils/src/validation/api-key/api-key.ts](packages/utils/src/validation/api-key/api-key.ts)
Schemas for API key management and public API input validation:
- `ZCreateApiKey` -- `{ name?: string }`
- `ZRevokeApiKeyParam` -- `{ keyId: string (uuid) }`
- Public API schemas for course CRUD, audience CRUD, pagination, etc.
## 5. Public API Routes (Versioned)
### Router structure
New directory: `apps/api/src/routes/v1/`
Files:
- [apps/api/src/routes/v1/courses.ts](apps/api/src/routes/v1/courses.ts) -- Course endpoints
- [apps/api/src/routes/v1/audience.ts](apps/api/src/routes/v1/audience.ts) -- Audience endpoints
- [apps/api/src/routes/v1/index.ts](apps/api/src/routes/v1/index.ts) -- Combines sub-routers
Register in [apps/api/src/app.ts](apps/api/src/app.ts):
```typescript
.route('/v1', v1Router)
```
All v1 routes use `apiKeyMiddleware` (not session auth).
### Course Endpoints (`/v1/courses`)
| Method | Path | Description |
| ------ | -------------------------------- | -------------------------------------------------- |
| GET | `/v1/courses` | List all org courses (paginated) |
| POST | `/v1/courses` | Create a course |
| GET | `/v1/courses/:courseId` | Get course details |
| PUT | `/v1/courses/:courseId` | Update a course |
| DELETE | `/v1/courses/:courseId` | Soft-delete a course |
| GET | `/v1/courses/:courseId/export` | Export course content (lessons, exercises) as JSON |
| GET | `/v1/courses/:courseId/students` | List students enrolled in a course |
### Audience Endpoints (`/v1/audience`)
| Method | Path | Description |
| ------ | ------------------------- | --------------------------------- |
| GET | `/v1/audience` | List all org students (paginated) |
| POST | `/v1/audience` | Add a student to the org |
| GET | `/v1/audience/:studentId` | Get student details |
| PUT | `/v1/audience/:studentId` | Update student info |
| DELETE | `/v1/audience/:studentId` | Remove student from org |
### API Key Management (session-authenticated, not public API)
New file: [apps/api/src/routes/api-key/api-key.ts](apps/api/src/routes/api-key/api-key.ts)
| Method | Path | Description |
| ------ | ----------------- | ------------------------------------------- |
| POST | `/api-key` | Generate new API key (returns raw key once) |
| GET | `/api-key` | List API keys for the org |
| DELETE | `/api-key/:keyId` | Revoke an API key |
Registered in `app.ts` as `.route('/api-key', apiKeyRouter)`. Protected by `authMiddleware` + `orgAdminMiddleware`.
## 6. Services
- [apps/api/src/services/api-key.ts](apps/api/src/services/api-key.ts) -- Key generation (crypto.randomBytes), hashing, CRUD logic
- [apps/api/src/services/v1/course.ts](apps/api/src/services/v1/course.ts) -- Public API course business logic (reuses existing queries from `@cio/db/queries/course`)
- [apps/api/src/services/v1/audience.ts](apps/api/src/services/v1/audience.ts) -- Public API audience business logic (reuses `getOrganizationAudience` and related queries)
## 7. Tests
New test files under `apps/api/src/__tests__/`:
- `api-key.test.ts` -- API key creation, listing, revocation, auth flow
- `v1/courses.test.ts` -- All course CRUD + export + students endpoints
- `v1/audience.test.ts` -- All audience CRUD endpoints
Tests will use Hono's `app.request()` test helper (no HTTP server needed). Mock the DB layer with `vi.mock()`.
Additionally, include OpenAPI contract coverage:
- verify all `/v1/*` and `/api-key/*` public-facing endpoints are represented in generated spec.
- ensure schema components are generated for request/response payloads.
## 8. Dashboard UI: Automation Page
### Sidebar Navigation
Add an "Automation" nav item in [apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts](apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts), placed before Settings, with `requiresAdmin: true`:
```typescript
{
titleKey: 'org_navigation.automation',
path: '/automation',
icon: AutomationIcon, // use Zap or Workflow icon from lucide
requiresAdmin: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$'
}
```
### Route
New file: [apps/dashboard/src/routes/org/[slug]/automation/+page.svelte](apps/dashboard/src/routes/org/[slug]/automation/+page.svelte)
### Page Structure
```
+----------------------------------------------+
| Automation |
| Connect ClassroomIO with your tools and |
| workflows. |
+----------------------------------------------+
| [ API ] [ Zapier ] [ Webhooks ] |
+----------------------------------------------+
| |
| (Tab content based on selection) |
| |
+----------------------------------------------+
```
- Title: "Automation" with description text
- Uses `Tabs` from `@cio/ui/base/tabs`
- Three tabs: **API**, **Zapier**, **Webhooks**
### API Tab Content
**Free plan users:** Show `UpgradeBanner` component (same pattern as other pages). Disable all controls.
**Paid plan users:**
- API key display field (masked by default, e.g. `cio_live_••••••••••••a1b2`)
- "Show" / "Hide" toggle button to reveal/mask the key
- "Copy" button to copy to clipboard
- "Regenerate" button (with confirmation) to create a new key and revoke the old one
- "Read the Docs" button/link pointing to the API documentation URL
### API Class
New file: [apps/dashboard/src/lib/features/automation/api/automation.svelte.ts](apps/dashboard/src/lib/features/automation/api/automation.svelte.ts)
Extends `BaseApi`, methods:
- `fetchApiKeys()` -- GET `/api-key`
- `createApiKey()` -- POST `/api-key`
- `revokeApiKey(keyId)` -- DELETE `/api-key/:keyId`
### Frontend Types
New file: [apps/dashboard/src/lib/features/automation/utils/types.ts](apps/dashboard/src/lib/features/automation/utils/types.ts)
### Translations
Add keys to [apps/dashboard/src/lib/utils/translations/en.json](apps/dashboard/src/lib/utils/translations/en.json):
- `org_navigation.automation`
- `automation.title`, `automation.description`
- `automation.tabs.api`, `automation.tabs.zapier`, `automation.tabs.webhooks`
- `automation.api.key_label`, `automation.api.copy`, `automation.api.regenerate`, `automation.api.read_docs`
- `automation.api.upgrade_prompt`
- `automation.zapier.coming_soon`, `automation.webhooks.coming_soon`
## 9. Versioning Strategy
- All public API routes live under `/v1/`. When v2 is needed, a new `apps/api/src/routes/v2/` directory is created with its own router, registered as `.route('/v2', v2Router)`.
- The v1 router remains untouched, ensuring backward compatibility.
- The API key middleware is version-agnostic and shared across all versions.
## 10. OpenAPI Spec Requirements
### Route Annotation Requirements
All new public API routes in this PRD must be OpenAPI-documented:
- add route metadata using existing Hono OpenAPI patterns (`describeRoute`, validators/schemas).
- ensure request params/query/body and success/error response shapes are declared.
- avoid undocumented routes under `/v1/*` and `/api-key/*`.
### Spec Generation and Publishing
Use existing script in `apps/api/scripts/upload-openapi-spec.ts`:
- generate spec from app routes.
- save local output to `apps/api/dist/openapi/openapi.json`.
- upload dated + latest specs to R2 (`openapi/openapi-YYYY-MM-DD.json`, `openapi/openapi-latest.json`).
Required update flow after public API changes:
1. implement/adjust routes and schemas.
2. run `pnpm --filter @cio/api upload:openapi`.
3. verify `openapi-latest.json` contains new endpoints.
### CI/Verification Gate
Add a verification step in this initiative to prevent stale API docs:
- PR checklist requires successful OpenAPI generation for API changes.
- include generated-spec validation in API CI for public API route changes.
## 11. Docs Integration (Fumadocs OpenAPI)
Integrate API docs in `apps/docs` following Fumadocs OpenAPI integration guidance:
- reference: `https://www.fumadocs.dev/docs/integrations/openapi`
- wire docs app to render OpenAPI spec from latest published source.
Target implementation tasks:
1. Add required Fumadocs OpenAPI integration package(s) to `apps/docs/package.json`.
2. Add a docs route/page named `API` at `/api` in `apps/docs/src/routes` (or equivalent docs content route).
3. Configure the page to load `openapi-latest.json` URL (env-driven preferred, with production fallback).
4. Add an `API` entry to docs navigation in `apps/docs/content/docs/meta.json`.
5. Validate local docs build still passes with the OpenAPI page.
Suggested file touchpoints:
- `apps/docs/package.json`
- `apps/docs/src/routes/*` (new `/api` route) or `apps/docs/content/docs/api.mdx`
- `apps/docs/content/docs/meta.json`
- optional docs config/util files for OpenAPI URL env handling
## 12. Build and Verification Commands
1. `pnpm --filter @cio/api build`
2. `pnpm --filter @cio/api upload:openapi`
3. `pnpm --filter @cio/docs build`
4. Manual check: docs navigation shows `API`, open `/api`, and verify new endpoints appear.
Note: Follow AGENTS.md for code practices.
+488
View File
@@ -0,0 +1,488 @@
# Public Courses PRD
## Purpose
Enable course creators to publish "Public Courses" - a new course type where lesson content is fully accessible without signup, while interactive features (quizzes, exercises, progress tracking) require authentication. This creates a low-friction entry point for learners and a lead generation channel for organizations.
## Problem Statement
Currently, all courses require signup before accessing any content. This creates friction:
- Learners abandon at signup gate before seeing course value
- Organizations can't use courses as top-of-funnel content marketing
- No way to offer "try before you buy" experiences
- SEO discoverability is limited (landing pages only, not deep content)
## Product Goals
1. Create a third course type: **Public Course** (alongside Live Class, Self Paced)
2. Allow anonymous browsing of full lesson content
3. Gate interactive features behind authentication (soft nudge, not hard wall)
4. Drive conversions through value demonstration, not forced signup
5. Maintain clear UX distinction between guest and authenticated modes
## Non-Goals (v1)
- Partial lesson locking (first X lessons free)
- Per-lesson public/private toggles
- Anonymous quiz attempts with delayed signup
- Public course certificates
- Revenue sharing for public courses
## User Roles & Permissions
### Anonymous/Guest User
- View all lesson content (video, text, embeds)
- Navigate between lessons
- See course outline/structure
- **Cannot:** Take quizzes, submit exercises, track progress, bookmark
### Authenticated User
- All guest capabilities PLUS:
- Take quizzes and exercises
- Track progress (lessons completed, quiz scores)
- Bookmark/save courses
- Participate in discussions (if enabled)
### Course Creator/Admin
- Choose course type at creation: Live Class | Self Paced | **Public Course**
- Convert existing courses to Public (with warning about implications)
- View analytics: guest views vs authenticated engagement
## Functional Requirements
### 1. Course Type Selection
#### At Course Creation
```
Create New Course
[○] Live Class - Scheduled sessions with instructor
[○] Self Paced - Learner progresses at own pace
[●] Public Course - Open access, signup required for quizzes
[Continue] → Course Setup Wizard
```
#### Course Type Indicator
- Public courses show "Public" badge on cards (landing page, dashboard)
- Icon: `Globe` or `Unlock` to signify openness
- Color: Distinct from Live (red) and Self Paced (blue) - suggest green or teal
### 2. Anonymous Content Access
#### Lesson View (Guest Mode)
```
┌─────────────────────────────────────────────────────────┐
│ Course: Introduction to Python [Sign In] │
│ │
│ ┌──────────────┐ ┌─────────────────────────────────┐ │
│ │ Lesson 3/12 │ │ Video Player / Lesson Content │ │
│ │ │ │ │ │
│ │ ◉ Lesson 1 │ │ [Full content accessible] │ │
│ │ ◉ Lesson 2 │ │ │ │
│ │ ◍ Lesson 3 │ │ │ │
│ │ ○ Lesson 4 │ │ │ │
│ │ ... │ │ │ │
│ └──────────────┘ └─────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ 💡 Sign in to: │ │
│ │ • Track your progress │ │
│ │ • Take practice quizzes │ │
│ │ • Earn a certificate │ │
│ │ │ │
│ │ [Create Free Account] │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
#### Access Rules
| Feature | Guest | Authenticated |
|---------|-------|---------------|
| View lesson content | ✅ | ✅ |
| Navigate lessons | ✅ | ✅ |
| See course outline | ✅ | ✅ |
| View quiz content | ✅ (read-only) | ✅ |
| Submit quiz answers | ❌ | ✅ |
| Submit exercises | ❌ | ✅ |
| Progress tracking | ❌ (localStorage only) | ✅ |
| Bookmark/continue | ❌ | ✅ |
| Discussion forum | Read-only | Read + Write |
### 3. Conversion Nudges (Soft Gates)
#### Contextual Prompts
1. **On Quiz View** (guest):
```
┌────────────────────────────────────────┐
│ 📋 Practice Quiz: Variables & Types │
│ │
│ [Question content visible] │
│ │
│ ┌────────────────────────────────────┐ │
│ │ 🚀 Ready to test your knowledge? │ │
│ │ │ │
│ │ Sign in to take this quiz and │ │
│ │ track your progress. │ │
│ │ │ │
│ │ [Sign In to Continue] │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────┘
```
2. **After Lesson Completion** (guest):
```
Nice work completing this lesson! 🎉
[Sign in] to save your progress and pick up
where you left off next time.
[Continue as Guest] [Create Account]
```
3. **Progress Persistence Banner** (guest, after 2+ lessons):
```
⚠️ You're making great progress! Sign in to save
your place before you leave.
[Dismiss] [Save My Progress]
```
#### Nudge Strategy
- **Never block content** with modal on page load
- **Inline prompts** within context (quiz area, lesson end)
- **Dismissible** - guests can continue browsing
- **Frequency cap** - max 1 prompt per session per nudge type
### 4. Anonymous Progress (localStorage)
#### What We Track (Guest)
```typescript
// localStorage key: `guest_progress_{courseId}`
{
courseId: "course_xxx",
lastVisitedLessonId: "lesson_123",
completedLessons: ["lesson_001", "lesson_002"],
totalTimeSpent: 1847, // seconds
firstVisit: "2026-02-19T10:00:00Z",
lastVisit: "2026-02-19T14:30:00Z"
}
```
#### Conversion to Authenticated
- On signup/login, prompt: "Continue course from where you left off?"
- Merge localStorage progress into authenticated account
- Clear localStorage after successful merge
### 5. Landing Page Integration
#### New "Public Courses" Section
```
Our Courses
[Live Classes] [Self Paced] [Public Courses]
Explore free courses - no signup required to start learning!
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ [thumbnail] │ │ [thumbnail] │ │ [thumbnail] │
│ │ │ │ │ │
│ Python │ │ Web Design │ │ Excel │
│ Basics │ │ Fundamentals│ │ for Work │
│ │ │ │ │ │
│ 🌐 Public │ │ 🌐 Public │ │ 🌐 Public │
│ ⭐ 4.8 (234)│ │ ⭐ 4.6 (189)│ │ ⭐ 4.9 (512)│
│ │ │ │ │ │
│ [Start │ │ [Start │ │ [Start │
│ Learning] │ │ Learning] │ │ Learning] │
└─────────────┘ └─────────────┘ └─────────────┘
```
#### Course Card Badges
- **Live Class**: "📅 Live" badge (red)
- **Self Paced**: "📚 Self Paced" badge (blue)
- **Public Course**: "🌐 Public" badge (green/teal)
### 6. URL Structure
```
# Public course lesson (accessible to all)
/course/{courseSlug}/lesson/{lessonSlug}
# Same URL works for both guest and authenticated
# Server renders content based on auth state
# No separate /public/ prefix needed
```
### 7. SEO & Sharing
#### Meta Tags for Public Courses
```html
<meta name="description" content="Free course: Learn Python basics. No signup required.">
<meta property="og:type" content="article">
<meta property="og:availability" content="public">
```
#### Social Sharing Card
```
┌────────────────────────┐
│ │
│ [Course Thumbnail] │
│ │
├────────────────────────┤
│ Python Basics │
│ Free course - start │
│ learning instantly! │
│ │
│ 🌐 classroomio.com │
└────────────────────────┘
```
## Technical Requirements
### Data Model Changes
#### Course Table
```typescript
// Add to existing course schema
type CourseType = 'LIVE_CLASS' | 'SELF_PACED' | 'PUBLIC';
interface Course {
id: string;
name: string;
slug: string;
// ... existing fields
// NEW
type: CourseType; // default: 'SELF_PACED'
isPublic: boolean; // computed: type === 'PUBLIC'
// Analytics (NEW)
guestViews: number; // counter
authenticatedViews: number; // counter
conversionCount: number; // guests who signed up and enrolled
}
```
#### Enrollment Handling
- **Guest**: No enrollment record, localStorage tracking only
- **Authenticated**: Auto-enrolled on first authenticated interaction (quiz attempt, etc.)
- No explicit "Enroll" button for public courses - just start learning
### API Changes
#### New/Modified Endpoints
```typescript
// GET /courses/:slug/lessons/:lessonId
// Response changes based on auth:
// - Guest: lesson content + "requiresAuth" flags for interactive elements
// - Auth: lesson content + user's progress + full interactivity
// POST /courses/:slug/enroll (auto-triggered for public courses)
// Called implicitly when authenticated user first interacts
// GET /courses/public
// List public courses for landing page
// No auth required
// POST /analytics/guest-event
// Fire-and-forget guest activity tracking (privacy-safe)
```
### Permission Matrix
| Action | Guest | Authenticated | Admin |
|--------|-------|---------------|-------|
| View lesson content | ✅ | ✅ | ✅ |
| View quiz questions | ✅ (no answers) | ✅ | ✅ |
| Submit quiz | ❌ | ✅ | ✅ |
| Submit exercise | ❌ | ✅ | ✅ |
| See "correct answers" | ❌ | ✅ | ✅ |
| Post in discussion | ❌ | ✅ | ✅ |
| View progress stats | ❌ (local only) | ✅ | ✅ |
| Download resources | Configurable* | ✅ | ✅ |
\* Depends on `allowGuestDownloads` setting |
## Analytics & Success Metrics
### Key Metrics
1. **Guest to Auth Conversion Rate**: % of guests who sign up
2. **Guest Engagement**: Avg lessons viewed per guest session
3. **Time to Signup**: How many lessons before conversion
4. **Public Course Traffic**: % of total site traffic from public courses
5. **SEO Impact**: Organic search traffic to course pages
### Tracking Events
```typescript
// Guest events (anonymous, privacy-safe)
- public_course_viewed
- public_lesson_completed
- conversion_prompt_shown
- conversion_prompt_clicked
- guest_signup_initiated (from course context)
// Post-conversion
- guest_progress_merged
- public_course_enrolled (after signup)
```
## UX Considerations
### Clear Communication
- Banner on first visit: "You're browsing in guest mode. [Sign in] for full features."
- Persistent subtle indicator: "👤 Guest Mode" in header
- Tooltips on locked features explain why signup is needed
### Seamless Transition
- After signup, return to exact lesson/position
- Preserve scroll position
- Auto-merge localStorage progress
### No Dead Ends
- Every "locked" feature has clear path forward
- Alternative actions for guests (e.g., "Preview quiz questions" instead of "Take quiz")
## Future Enhancements (Post-v1)
1. **Course Previews**: Any course can have first lesson public
2. **Anonymous Quiz Attempts**: Try once, see score, signup to save
3. **Public Course Analytics Dashboard**: Creator insights on guest engagement
4. **Email Capture**: "Get notified when new public courses launch"
5. **Paywall Option**: Public → Paid conversion for premium tier
## Success Criteria
1. Guest users can view full course content without any signup friction
2. Conversion prompts appear contextually without being intrusive
3. Public courses appear on landing page with clear distinction
4. Post-signup experience seamlessly continues from guest session
5. No significant bounce rate increase on public course pages
6. Measurable conversion rate from guest to authenticated user
## Open Questions (Resolved)
1. ✅ **Downloadables** - Configurable per course (creator decides: public or gated)
2. ✅ **Live viewer count** - Yes, show "X people learning now" for social proof
3. ✅ **Donations** - Configurable Stripe link for "support this course"
4. ✅ **Revert to private** - Yes, creators can convert public → self-paced (with warning)
## Additional Course Settings for Public Courses
### Creator Configuration Options
When creating/editing a public course, creators can configure:
```typescript
interface PublicCourseSettings {
// Access control
allowGuestDownloads: boolean; // default: true
// Social proof
showLiveViewerCount: boolean; // default: true
// Monetization
acceptDonations: boolean; // default: false
donationLink?: string; // Stripe payment link
suggestedAmount?: number; // e.g., 5, 10, 25 USD
// Conversion
showConversionNudges: boolean; // default: true
nudgeFrequency: 'low' | 'medium' | 'high'; // default: 'medium'
}
```
#### UI: Public Course Settings Panel
```
Public Course Settings
─────────────────────────────
☑ Allow guests to download resources
[?] When checked, PDFs and files are downloadable without signup
☑ Show live viewer count
[?] Displays "X people learning now" on course page
☐ Accept donations via Stripe
Stripe Payment Link: [____________________]
Suggested Amount: $[10] USD
[?] Add a "Support this course" button
Conversion Nudges
(•) Low - After 3+ lessons and quiz views
( ) Medium - After 2+ lessons and at quizzes
( ) High - After every lesson and on all interactive elements
```
### Live Viewer Count Display
```
┌────────────────────────────────────────┐
│ Python Basics 🌐 Public │
│ │
│ 👥 24 people learning now │
│ ⭐ 4.8 (234 reviews) │
│ │
│ [💝 Support $10] [Start Learning →] │
└────────────────────────────────────────┘
```
**Implementation:**
- WebSocket or polling for real-time count
- Count unique sessions in last 15 minutes
- Fallback to "Popular" badge if count < 5
### Download Access Control
| Resource Type | Guest (allowed) | Guest (blocked) |
|--------------|-----------------|-----------------|
| Video lessons | ✅ Always | ✅ Always |
| Text content | ✅ Always | ✅ Always |
| PDF resources | ✅ Download | 🔒 Sign in to download |
| Code files | ✅ Download | 🔒 Sign in to download |
| Worksheets | ✅ Download | 🔒 Sign in to download |
### Reverting Public → Private
Creators can change course type, but with safeguards:
```
⚠️ Change Course Type
You are about to convert "Python Basics" from Public to Self Paced.
This will:
• Require login to access all content
• Preserve existing enrolled students' access
• Remove course from Public Courses listing
• Break any shared public links (will redirect to login)
[X] I understand this affects [1,247] guest learners
[Cancel] [Convert to Self Paced]
```
**Grace Period:**
- Existing guest sessions get 7-day grace period
- Show banner: "This course is now private. Sign up to continue."
- After 7 days, all guest access revoked
## Updated Data Model
### Course Table Additions
```typescript
interface Course {
// ... existing fields
type: 'LIVE_CLASS' | 'SELF_PACED' | 'PUBLIC';
// Public course settings (nullable for non-public)
publicSettings?: {
allowGuestDownloads: boolean;
showLiveViewerCount: boolean;
acceptDonations: boolean;
donationLink?: string;
suggestedAmount?: number;
showConversionNudges: boolean;
nudgeFrequency: 'low' | 'medium' | 'high';
};
// Analytics
currentViewers: number; // real-time-ish count
totalGuestViews: number; // lifetime
donationTotal?: number; // if accepting donations
}
```
+308
View File
@@ -0,0 +1,308 @@
# Public Courses Implementation Plan
## Scope Lock
1. **Course type**: Third option alongside Live Class and Self Paced
2. **Content access**: Full lesson content public, quizzes/exercises gated
3. **Conversion**: Soft nudges only, never hard modal on load
4. **Progress**: localStorage for guests, auto-merge on signup
5. **Landing page**: New "Public Courses" tab with distinct badge
6. **URL**: Same URL structure for guests and authenticated users
7. **SEO**: Public courses indexable with proper meta tags
## Delivery Phases
### Phase A: Data Model & Course Type (Days 1-2)
- Add `type` column to course table
- Update validation schemas
- Modify course creation wizard
### Phase B: Anonymous Content Access (Days 3-5)
- Make lesson endpoints work without auth for public courses
- Add permission checks for gated features
- Implement localStorage progress tracking
### Phase C: Conversion UX (Days 6-8)
- Build nudge components (quiz gate, lesson complete, progress banner)
- Implement guest mode UI indicators
- Add signup flow with progress merge
### Phase D: Landing Page (Days 9-10)
- Add Public Courses tab to landing page
- Create course type badges
- Update course cards with type indicators
### Phase E: Analytics & Polish (Days 11-12)
- Implement guest analytics tracking
- Add conversion funnel metrics
- Testing and edge cases
---
## Ticket Breakdown
| ID | Area | Task | Key Files | Dependencies | Done When |
|----|------|------|-----------|--------------|-----------|
| PC-A1 | DB | Add `type` enum to course schema | `packages/db/src/schema.ts` | None | Migration runs, `type` column exists with default 'SELF_PACED' |
| PC-A2 | DB | Update course queries to include type | `packages/db/src/queries/course/course.ts` | PC-A1 | Queries return course type |
| PC-A3 | Utils | Add course type validation | `packages/utils/src/validation/course/course.ts` | PC-A1 | Zod schema accepts 'LIVE_CLASS' \| 'SELF_PACED' \| 'PUBLIC' |
| PC-A4 | API | Update course create/update routes | `apps/api/src/routes/course/course.ts` | PC-A3 | Can create course with type 'PUBLIC' |
| PC-B1 | API | Make lesson content endpoint public-safe | `apps/api/src/routes/lesson/lesson.ts` | PC-A2 | Guests can GET lesson content for public courses |
| PC-B2 | API | Add permission middleware for gated features | `apps/api/src/middleware/permission.ts` | PC-B1 | Quiz/exercise endpoints check auth for public courses |
| PC-B3 | Dashboard | Implement localStorage progress tracking | `apps/dashboard/src/lib/utils/guest-progress.ts` | None | Can save/load guest progress from localStorage |
| PC-B4 | Dashboard | Add guest mode detection | `apps/dashboard/src/lib/stores/auth.svelte.ts` | None | Store exposes `isGuest` computed property |
| PC-C1 | Dashboard | Build quiz gate component | `apps/dashboard/src/lib/features/lesson/components/quiz-gate.svelte` | PC-B2 | Shows nudge when guest views quiz |
| PC-C2 | Dashboard | Build lesson complete nudge | `apps/dashboard/src/lib/features/lesson/components/lesson-complete-nudge.svelte` | PC-B3 | Shows after lesson completion for guests |
| PC-C3 | Dashboard | Build guest progress banner | `apps/dashboard/src/lib/features/lesson/components/guest-progress-banner.svelte` | PC-B3 | Shows after 2+ lessons viewed |
| PC-C4 | Dashboard | Implement progress merge flow | `apps/dashboard/src/lib/features/auth/utils/merge-progress.ts` | PC-B3 | Merges localStorage to account on signup |
| PC-C5 | Dashboard | Add guest mode UI indicators | `apps/dashboard/src/lib/features/ui/guest-indicator.svelte` | PC-B4 | Shows "Guest Mode" in header |
| PC-D1 | Dashboard | Update landing page tabs | `apps/dashboard/src/routes/org/[slug]/landing/+page.svelte` | PC-A4 | Has Live / Self Paced / Public tabs |
| PC-D2 | Dashboard | Create course type badge component | `apps/dashboard/src/lib/features/course/components/course-type-badge.svelte` | None | Shows Live/Self Paced/Public with colors |
| PC-D3 | Dashboard | Update course cards | `apps/dashboard/src/lib/features/course/components/course-card.svelte` | PC-D2 | Cards show type badge |
| PC-D4 | API | Add public courses list endpoint | `apps/api/src/routes/course/course.ts` | PC-A4 | `GET /courses/public` returns public courses |
| PC-E1 | API | Add guest analytics tracking | `apps/api/src/routes/analytics/analytics.ts` | None | Fire-and-forget endpoint for guest events |
| PC-E2 | Dashboard | Implement conversion tracking | `apps/dashboard/src/lib/utils/analytics/public-courses.ts` | PC-E1 | Tracks guest → auth conversions |
| PC-E3 | API | Add live viewer tracking | `apps/api/src/services/analytics/live-viewers.ts` | PC-B1 | Tracks active sessions per course |
| PC-E4 | Dashboard | Add live viewer count UI | `apps/dashboard/src/lib/features/course/components/live-viewer-count.svelte` | PC-E3 | Shows "X people learning now" |
| PC-E5 | Testing | Write integration tests | `apps/api/tests/public-courses.test.ts` | All above | Core flows tested |
---
## API Changes
### New Endpoints
```typescript
// GET /courses/public
// List all public courses (no auth required)
// Returns: Course[] filtered by type='PUBLIC' and status='PUBLISHED'
// POST /analytics/guest-event
// Track guest activity (privacy-safe, no PII)
// Body: { event: string, courseId: string, lessonId?: string, timestamp: number }
// Returns: 204 No Content
```
### Modified Endpoints
```typescript
// POST /courses
// Request body now accepts: type: 'LIVE_CLASS' | 'SELF_PACED' | 'PUBLIC'
// GET /courses/:slug/lessons/:lessonId
// Response now includes:
// - requiresAuth: boolean (for interactive elements)
// - userProgress: null | Progress (null for guests)
// POST /courses/:slug/enroll
// Auto-enrolls authenticated user to public course on first interaction
// No-op if already enrolled
```
---
## Data Flow
### Guest Viewing Public Course
```
1. Guest clicks course card on landing page
2. Server checks: course.type === 'PUBLIC' → allow
3. Lesson content rendered server-side (SEO-friendly)
4. Client hydrates with guest mode indicators
5. localStorage checked for saved progress
6. Guest browses lessons freely
On quiz view:
7. Client checks auth status
8. Guest sees inline nudge: "Sign in to take quiz"
9. Quiz questions visible but submit disabled
On signup:
10. Progress merged from localStorage
11. Auto-enrolled in course
12. Redirected back to last viewed lesson
```
---
## UI Components Needed
### Course Type Badge
```svelte
<CourseTypeBadge type="PUBLIC" />
// Shows: 🌐 Public (green/teal)
```
### Quiz Gate
```svelte
<QuizGate isGuest={true}>
<QuizContent questions={questions} />
</QuizGate>
// Renders nudge overlay when guest tries to interact
```
### Guest Progress Banner
```svelte
<GuestProgressBanner
lessonsCompleted={3}
totalLessons={12}
onSignUp={() => goto('/signup')}
/>
```
### Landing Page Tabs
```svelte
<Tabs value={activeTab}>
<Tabs.List>
<Tabs.Trigger value="live">📅 Live Classes</Tabs.Trigger>
<Tabs.Trigger value="selfpaced">📚 Self Paced</Tabs.Trigger>
<Tabs.Trigger value="public">🌐 Public Courses</Tabs.Trigger>
</Tabs.List>
</Tabs>
```
---
## localStorage Schema
```typescript
// Key: `cio_guest_progress`
interface GuestProgressStore {
version: 1;
courses: {
[courseId: string]: {
lastVisitedLessonId: string;
completedLessons: string[];
totalTimeSpent: number; // seconds
firstVisit: string; // ISO date
lastVisit: string; // ISO date
}
};
sessionId: string; // anonymous fingerprint for analytics
}
```
---
## Permission Matrix
| Endpoint | Guest (Public Course) | Auth (Public Course) | Any (Private Course) |
|----------|----------------------|----------------------|---------------------|
| GET /courses/:slug | ✅ | ✅ | ✅ (if enrolled) |
| GET /lessons/:id | ✅ | ✅ | ✅ (if enrolled) |
| GET /quizzes/:id | ✅ (no answers) | ✅ | ✅ (if enrolled) |
| POST /quizzes/:id/submit | ❌ | ✅ | ✅ (if enrolled) |
| POST /exercises/:id/submit | ❌ | ✅ | ✅ (if enrolled) |
| GET /progress | ❌ | ✅ | ✅ (own progress) |
| POST /discussions | ❌ | ✅ | ✅ (if enrolled) |
---
## Analytics Events
```typescript
// Guest events (no userId, use sessionId)
interface GuestEvent {
event:
| 'public_course_viewed'
| 'public_lesson_started'
| 'public_lesson_completed'
| 'public_quiz_viewed'
| 'conversion_prompt_shown'
| 'conversion_prompt_clicked'
| 'guest_signup_initiated';
courseId: string;
lessonId?: string;
sessionId: string;
timestamp: number;
metadata?: Record<string, unknown>;
}
// Conversion events (with userId)
interface ConversionEvent {
event: 'guest_progress_merged' | 'public_course_enrolled';
userId: string;
courseId: string;
previousSessionId: string;
lessonsCompletedBeforeSignup: number;
timestamp: number;
}
```
---
## Acceptance Criteria
### Phase A
- [ ] Can create course with type 'PUBLIC' via API
- [ ] Course type persists and returns in responses
- [ ] Existing courses default to 'SELF_PACED'
### Phase B
- [ ] Guest can view lesson content for public courses
- [ ] Guest cannot submit quizzes/exercises
- [ ] localStorage saves guest progress
- [ ] Progress loads from localStorage on return visit
### Phase C
- [ ] Quiz gate shows for guests viewing quizzes
- [ ] Lesson complete nudge shows after finishing lesson
- [ ] Progress banner shows after 2+ lessons
- [ ] Signup merges localStorage progress to account
- [ ] Post-signup redirects to correct lesson
### Phase D
- [ ] Landing page has Public Courses tab
- [ ] Course cards show correct type badge
- [ ] Public courses list endpoint works without auth
### Phase E
- [ ] Guest events tracked (privacy-safe)
- [ ] Conversion funnel metrics calculated
- [ ] Live viewer count displays correctly
- [ ] Donation button appears when enabled
- [ ] Download permissions enforced correctly
- [ ] Course type conversion works with grace period
- [ ] Integration tests pass
- [ ] No console errors in guest mode
---
## Verification Commands
```bash
# Build all packages
pnpm --filter @cio/utils build
pnpm --filter @cio/db build
pnpm --filter @cio/api build
pnpm --filter @cio/dashboard build
# Run tests
pnpm --filter @cio/api test public-courses
# Type check
pnpm --filter @cio/dashboard check
```
---
## Rollout Plan
1. **Feature Flag**: Add `PUBLIC_COURSES_ENABLED` env var
2. **Internal Testing**: Enable for team org only
3. **Beta Users**: Select 5 orgs to test
4. **Full Rollout**: Remove feature flag
5. **Announcement**: Blog post + in-app notification
---
## Risk Mitigation
| Risk | Mitigation |
|------|------------|
| Content scraping | Rate limiting on lesson endpoints; consider Cloudflare bot detection |
| Live viewer accuracy | Use heartbeat every 2 min, expire after 5 min of inactivity |
| Donation fraud | Validate Stripe link format; no server-side payment processing |
| SEO duplicate content | Canonical URLs; no separate /public/ path |
| localStorage quota exceeded | Max 10 courses tracked; LRU eviction |
| Guest analytics spam | Rate limit analytics endpoint; validate sessionId format |
| Conversion nudge fatigue | Frequency caps; dismissible with "Don't show again" |
+141
View File
@@ -0,0 +1,141 @@
# Secure Student Invite Flow
## Purpose
Document the current secure student invite architecture, what is implemented right now, and what should be implemented next to make the invite system excellent for a modern course platform.
## Scope
- Student invitations into a course
- Security controls around link generation and acceptance
- Teacher/admin management surface for invite links
## Current Flow (Implemented)
### 0. Public landing page enroll button (free courses)
1. User clicks Enroll on a free course landing page.
2. Frontend calls `POST /invite/student/public-link` with `courseId`.
3. Backend validates course is active, published, and accepting students.
4. Backend creates a one-time secure invite token (24h expiry).
5. Frontend redirects user to `/invite/s/:token`.
### 1. Teacher creates invite link
1. Teacher opens the invite modal in the course people page.
2. Frontend calls `POST /course/:courseId/invites`.
3. Backend creates a cryptographically random token.
4. Backend stores only `sha256(token)` in `course_invite.token_hash`.
5. Backend returns a single usable invite URL: `/invite/s/:token`.
### 2. Student opens invite link
1. Student hits invite page with token in URL.
2. Frontend calls `GET /invite/student/:token/preview`.
3. Backend hashes token and looks up invite by `token_hash`.
4. Backend returns invite + course + organization preview data and computed status.
### 3. Student accepts invite
1. Student clicks Join.
2. Frontend calls `POST /invite/student/:token/accept` (authenticated route).
3. Backend performs checks in a transaction:
- invite exists
- invite role is student
- invite is active (not expired/revoked/used up)
- course is active and published
- course allows new students
- optional email/domain restrictions pass
- if restricted invite, verified email is required
4. Backend idempotently handles already-joined students.
5. Backend inserts missing org membership + group membership.
6. Backend atomically increments invite usage with race-safe condition.
7. Backend sends welcome email to student and notification emails to teachers.
### 4. Teacher can revoke
1. Teacher calls `POST /course/:courseId/invites/:inviteId/revoke`.
2. Invite is marked revoked and immediately blocked from future joins.
## Functionalities Implemented
### Database
- New `course_invite` table in `packages/db/src/schema.ts`.
- Fields include:
- `tokenHash`, `expiresAt`, `maxUses`, `usedCount`, `isRevoked`
- `allowedEmails`, `allowedDomains`, `metadata`
- `createdByProfileId`, `lastUsedAt`, timestamps
- Indexes and uniqueness on token hash.
### API and Service Layer
- Course-level invite management routes:
- `GET /course/:courseId/invites`
- `POST /course/:courseId/invites`
- `POST /course/:courseId/invites/:inviteId/revoke`
- Public/auth invite routes:
- `POST /invite/student/public-link`
- `GET /invite/student/:token/preview`
- `POST /invite/student/:token/accept`
- Invite validation schemas added for params and payloads.
- Legacy invite fallback removed; only secure tokenized invites are supported (including landing-page enroll flow).
### Security Controls
- High-entropy token generation (`crypto.randomBytes`).
- Token hashing at rest (no raw token stored in DB).
- Expiry, max-use, and revocation enforcement.
- Public free-course enroll flow issues one-time, short-lived secure links instead of payload-derived links.
- Optional allowlist restrictions (emails/domains).
- Verified-email enforcement for restricted invites.
- Transactional acceptance with optimistic concurrency guard on `usedCount`.
- Idempotent join behavior to avoid duplicate consumption.
### Dashboard / UX
- Invite modal now requests server-generated secure links.
- Copy-link and QR features use secure link output.
- Invite join page uses backend preview + accept endpoints.
- Invite join page shows clear blocked states for expired/revoked/used-up/unavailable course.
## What I Recommend Next (Priority Order)
### P0: Must-have next
1. Invite management UI in course people page:
- list invites
- show status and uses left
- revoke from UI
- copy existing invite link metadata (without exposing raw token again)
2. Per-invite presets:
- one-time link
- multi-use link
- short expiry presets (24h, 7d, 30d)
3. Full audit trail:
- who created/revoked invite
- who joined with invite
- timestamped acceptance history
### P1: High impact
1. Direct email invites (personalized):
- issue invite per recipient
- send one-click email invites
- track delivery/open/accept
2. Bulk invite import:
- CSV of emails
- domain validation and duplicate detection
3. Abuse protection:
- rate limits on preview/accept
- IP/device anomaly detection
### P2: Advanced platform features
1. SSO/domain auto-enrollment rules:
- trusted domains auto-map to org/course policies
2. Invite analytics dashboard:
- conversion funnel (created -> opened -> accepted)
- per-course acceptance rates
3. Expiry automation:
- automatic cleanup/archive
- reminders before expiration
## Suggested Product Standard for an Outstanding Course Platform
- Secure by default (hashed tokens, expiry, revocation, least privilege).
- Clear invite lifecycle (active, expired, used up, revoked) visible to staff.
- Excellent failure UX for students (explicit reason + next action).
- Strong governance (audit logs and admin controls).
- Scalable operations (bulk invite, email campaigns, analytics).
## Validation Status
- `pnpm --filter @cio/db build` passed.
- `pnpm --filter @cio/utils build` passed.
- `pnpm --filter @cio/api build` passed.
- `pnpm --filter @cio/dashboard build` passed (with unrelated pre-existing Svelte warnings).
+481
View File
@@ -0,0 +1,481 @@
# Selective Exercise Assignment PRD
## Status
- Draft
## Date
- March 4, 2026
## Implementation Plan
- See [implementation-plan.md](./implementation-plan.md) for phased delivery.
## Purpose
Enable instructors to assign an exercise to only selected students in a course, instead of all students by default, while keeping existing course behavior unchanged for non-targeted exercises.
## Problem Statement
Today, every exercise in a course is implicitly available to every enrolled student. This creates problems for common teaching workflows:
1. Remedial assignments cannot be sent to only struggling students.
2. Advanced assignments cannot be targeted to a smaller high-performing group.
3. Makeup tasks cannot be assigned privately without cloning content or creating separate courses.
4. Student analytics and completion rates are skewed when students are expected to ignore irrelevant exercises.
## Current-State Audit
| Area | Current State | Gap for Selective Assignment |
| --- | --- | --- |
| `exercise` data model | Global `isUnlocked`, no audience targeting | No way to represent "who this exercise is for" |
| Course membership | `groupmember` links profile to course group | No exercise-level audience mapping |
| Course content fetch | `getCourseContentItems(courseId, profileId?)` returns all exercises in course | Students always see all exercises |
| Exercise detail fetch | `GET /course/:courseId/exercise/:exerciseId` allows any course member | Unassigned students can still fetch exercise payload |
| Exercise submission | `POST /course/:courseId/exercise/:exerciseId/submission` only checks course membership | No server-side assignment guard |
| LMS exercises feed | `getLMSExercises(profileId, orgId)` loads all exercises in enrolled courses | Unassigned exercises appear in student LMS views |
| Progress & analytics | Exercise totals are computed from all course exercises | Unassigned exercises lower completion rates unfairly |
| Instructor UI | Exercise page has content editing only | No "assign to students" controls |
## Data Sources Checked
- `packages/db/src/schema.ts`
- `packages/db/src/queries/course/content.ts`
- `packages/db/src/queries/exercise/exercise.ts`
- `packages/db/src/queries/exercise/lms.ts`
- `packages/db/src/queries/course/course.ts`
- `packages/db/src/queries/analytics/analytics.ts`
- `apps/api/src/routes/course/exercise.ts`
- `apps/api/src/services/exercise/exercise.ts`
- `apps/api/src/routes/course/course.ts`
- `apps/api/src/routes/organization/organization.ts`
- `apps/dashboard/src/lib/features/course/pages/exercise.svelte`
- `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts`
- `apps/dashboard/src/lib/features/course/components/lesson/content-list.svelte`
- `apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte`
## Confirmed Decisions
1. Default behavior remains unchanged: new and existing exercises are assigned to all students.
2. Selective assignment applies only to students (`ROLE.STUDENT`), never tutors/admins.
3. Assignment is stored against `groupmember.id` (course-scoped membership identity).
4. Unassigned students do not see targeted exercises in content trees or LMS exercise lists.
5. Direct API access is blocked server-side for unassigned students.
6. Existing submissions are preserved if a student is later unassigned.
7. Newly enrolled students are not auto-added to selectively assigned exercises.
8. Instructor analytics should not count unassigned exercises in a student's denominator.
## Goals
1. Let instructors set each exercise audience to either all students or selected students.
2. Ensure selective assignment is enforced consistently in all student-facing APIs.
3. Keep instructor and admin experience fully intact with visibility into all exercises.
4. Keep migration low risk and backward compatible.
5. Keep performance stable for courses with large student rosters.
## Non-Goals (v1)
- Assignment by invite email before student enrollment.
- Group rules like "assign by tag/domain/score threshold."
- Scheduled auto-assignment rules.
- Assignment for lessons (this PRD is exercise-only).
- Student-facing "why I am not assigned" messaging model beyond standard hidden behavior.
## Primary Users
- Instructors (admins/tutors) creating targeted learning paths.
- Students receiving only relevant exercises.
- Program managers reading analytics that reflect actual assigned workload.
## User Stories
1. As a tutor, I can assign Exercise A to only 12 students who need extra practice.
2. As a tutor, I can switch Exercise A back to all students later without recreating it.
3. As a student not assigned Exercise A, I should not see it in my course content list.
4. As a student assigned Exercise A, I can open and submit it like any normal exercise.
5. As an instructor, analytics should not mark unassigned students as incomplete for Exercise A.
## Functional Requirements
### FR-1: Assignment Modes per Exercise
Each exercise has one audience mode:
- `ALL_STUDENTS` (default)
- `SELECTED_STUDENTS`
Rules:
- `ALL_STUDENTS`: every current and future student in course group has access.
- `SELECTED_STUDENTS`: only explicitly assigned students have access.
- `SELECTED_STUDENTS` cannot be saved with an empty student list.
### FR-2: Instructor Assignment Management
Instructors can:
1. Open assignment settings on exercise page.
2. Switch mode between all students and selected students.
3. Search/select enrolled students (course members with `roleId = ROLE.STUDENT`).
4. Save assignment changes atomically.
5. See summary label such as:
- `Assigned to all students`
- `Assigned to 12 students`
### FR-3: Student Visibility and Access Control
For student viewers:
1. Unassigned exercises are excluded from course content payload.
2. Unassigned exercises are excluded from organization LMS exercises payload.
3. `GET /course/:courseId/exercise/:exerciseId` returns access-denied behavior for unassigned students.
4. `POST /course/:courseId/exercise/:exerciseId/submission` is rejected for unassigned students.
For non-student viewers (tutor/admin/org admin):
1. All exercises remain visible.
2. Full assignment metadata is available where needed.
### FR-4: Reassignment Semantics
When an instructor changes assignment:
1. `ALL_STUDENTS -> SELECTED_STUDENTS`:
- Removed students immediately lose access.
- Existing submissions from removed students remain visible to instructors.
2. `SELECTED_STUDENTS -> ALL_STUDENTS`:
- All students immediately gain access.
3. Updating selected list:
- Added students gain access immediately.
- Removed students lose access immediately.
### FR-5: Enrollment Behavior
1. New student joins course:
- Exercises in `ALL_STUDENTS` are accessible immediately.
- Exercises in `SELECTED_STUDENTS` are not accessible unless explicitly assigned.
2. Student removed from course:
- Related assignment rows are cleaned via FK cascade.
### FR-6: Analytics and Progress Correctness
For student-specific analytics and progress:
1. Exercise denominator includes only exercises assigned to that student.
2. Completion rate and average grade calculations use assigned exercise set.
3. Course-level aggregate analytics reuse corrected per-student data.
## Technical Design
### 1. Data Model
#### 1.1 New Enum on Exercise
```sql
CREATE TYPE EXERCISE_ASSIGNMENT_MODE AS ENUM ('ALL_STUDENTS', 'SELECTED_STUDENTS');
ALTER TABLE exercise
ADD COLUMN assignment_mode EXERCISE_ASSIGNMENT_MODE NOT NULL DEFAULT 'ALL_STUDENTS';
```
Drizzle (`packages/db/src/schema.ts`):
```typescript
export const exerciseAssignmentMode = pgEnum('EXERCISE_ASSIGNMENT_MODE', [
'ALL_STUDENTS',
'SELECTED_STUDENTS'
]);
// in exercise table
assignmentMode: exerciseAssignmentMode('assignment_mode').default('ALL_STUDENTS').notNull();
```
#### 1.2 New Join Table
```sql
CREATE TABLE exercise_assignment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
exercise_id UUID NOT NULL REFERENCES exercise(id) ON DELETE CASCADE,
group_member_id UUID NOT NULL REFERENCES groupmember(id) ON DELETE CASCADE,
assigned_by_profile_id UUID REFERENCES profile(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc'::text, now())
);
CREATE UNIQUE INDEX exercise_assignment_unique
ON exercise_assignment (exercise_id, group_member_id);
CREATE INDEX idx_exercise_assignment_exercise_id
ON exercise_assignment (exercise_id);
CREATE INDEX idx_exercise_assignment_group_member_id
ON exercise_assignment (group_member_id);
```
Design notes:
1. `group_member_id` ensures assignment is tied to course membership context.
2. Service layer validates each selected `groupmember` belongs to exercise course and is student role.
3. No denormalized `course_id` is added to avoid redundant consistency checks.
### 2. Query Layer Changes
#### 2.1 New exercise-assignment queries
Add query functions under `packages/db/src/queries/exercise`:
1. `listExerciseAssignments(exerciseId)`
2. `replaceExerciseAssignments(exerciseId, groupMemberIds, assignedByProfileId, tx?)`
3. `getExerciseAssignmentSummary(exerciseId)`
4. `isExerciseAssignedToGroupMember(exerciseId, groupMemberId)`
5. `resolveViewerExerciseAccess(courseId, exerciseId, profileId)` returning `{ roleId, groupMemberId, hasAccess }`
#### 2.2 Course content query filtering
Update `getCourseContentItems(courseId, profileId?)`:
1. Resolve viewer role/groupmember for this course when `profileId` exists.
2. For student viewer, include exercise row only when:
- `exercise.assignment_mode = 'ALL_STUDENTS'`, or
- assignment row exists for viewer `groupmember`.
3. For non-student or anonymous viewer, keep current behavior.
4. Keep completion query semantics unchanged except for filtered exercise set.
#### 2.3 Student analytics/progress query filtering
Update these functions to use assignment-aware exercise sets:
- `packages/db/src/queries/course/course.ts`
1. `getCourseProgress(courseId, profileId)`
- `packages/db/src/queries/analytics/analytics.ts`
1. `getUserExercisesStats(courseId, userId)`
2. `getProfileCourseProgress(courseId, profileId)`
- `packages/db/src/queries/exercise/lms.ts`
1. `getLMSExercises(profileId, orgId)`
### 3. API Design
#### 3.1 New validation schema
Add `packages/utils/src/validation/exercise/assignment.ts`:
```typescript
export const ZExerciseAssignmentUpdate = z
.object({
mode: z.enum(['ALL_STUDENTS', 'SELECTED_STUDENTS']),
studentIds: z.array(z.string().uuid()).default([])
})
.superRefine((value, ctx) => {
if (value.mode === 'SELECTED_STUDENTS' && value.studentIds.length === 0) {
ctx.addIssue({ code: 'custom', path: ['studentIds'], message: 'Select at least one student' });
}
});
```
#### 3.2 New routes
In `apps/api/src/routes/course/exercise.ts`:
1. `GET /course/:courseId/exercise/:exerciseId/assignment`
- Auth: `courseTeamMemberMiddleware`
- Response: mode + assigned student list + count
2. `PUT /course/:courseId/exercise/:exerciseId/assignment`
- Auth: `courseTeamMemberMiddleware`
- Body: `ZExerciseAssignmentUpdate`
- Behavior: atomic mode update + assignment replace
#### 3.3 Updated existing routes
1. `GET /course/:courseId/exercise`
- Student callers receive only assigned exercises
2. `GET /course/:courseId/exercise/:exerciseId`
- Student callers blocked if unassigned
3. `POST /course/:courseId/exercise/:exerciseId/submission`
- Student callers blocked if unassigned
### 4. Service Layer
Add service functions in `apps/api/src/services/exercise/exercise.ts`:
1. `getExerciseAssignment(exerciseId, courseId)`
2. `updateExerciseAssignment(exerciseId, courseId, mode, studentIds, actorProfileId)`
3. `assertStudentExerciseAccess(courseId, exerciseId, profileId)`
Access check behavior:
1. Tutor/admin/org-admin: allow.
2. Student:
- allow if mode is `ALL_STUDENTS`
- allow if mode `SELECTED_STUDENTS` and assignment exists
- deny otherwise
### 5. Frontend Design (Dashboard)
#### 5.1 Types
Add API-derived types in:
- `apps/dashboard/src/lib/features/course/utils/types.ts`
Types:
1. `GetExerciseAssignmentRequest`
2. `UpdateExerciseAssignmentRequest`
3. `ExerciseAssignmentSuccess`
#### 5.2 API class methods
Extend `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts`:
1. `getAssignment(courseId, exerciseId)`
2. `updateAssignment(courseId, exerciseId, payload)`
Use `this.execute<RequestType>()` pattern and zod validation mapping.
#### 5.3 Instructor UI
Add assignment panel component under:
- `apps/dashboard/src/lib/features/course/components/exercise/exercise-assignment-panel.svelte`
UI behavior:
1. Visible only for `ROLE.ADMIN` and `ROLE.TUTOR`.
2. Mode selector: all students vs selected students.
3. Student multi-select sourced from existing members API (filtered to student role).
4. Save CTA with success/error snackbar keys.
5. No hardcoded copy; all translation keys in `en.json`.
Integrate panel into:
- `apps/dashboard/src/lib/features/course/pages/exercise.svelte`
#### 5.4 Student UI behavior
No new explicit student controls.
Expected result:
1. Sidebar and lesson content lists naturally omit unassigned exercises because course content API is filtered.
2. Direct navigation to unassigned exercise path shows access-denied outcome from server response.
### 6. Error Handling
Add new API error code:
- `EXERCISE_NOT_ASSIGNED`
Recommended statuses:
1. Exercise detail read: `403` with `EXERCISE_NOT_ASSIGNED` for students.
2. Submission create: `403` with `EXERCISE_NOT_ASSIGNED`.
### 7. Migration and Backward Compatibility
1. Existing exercises default to `assignment_mode = ALL_STUDENTS`.
2. `exercise_assignment` starts empty.
3. No data backfill is required.
4. Existing clients continue to work because default mode preserves current behavior.
## API Response Shapes (Proposed)
### GET assignment
```json
{
"success": true,
"data": {
"exerciseId": "uuid",
"mode": "SELECTED_STUDENTS",
"assignedCount": 2,
"students": [
{
"groupMemberId": "uuid",
"profileId": "uuid",
"fullname": "Ada Lovelace",
"email": "ada@example.com"
}
]
}
}
```
### PUT assignment
```json
{
"mode": "SELECTED_STUDENTS",
"studentIds": ["groupmember-uuid-1", "groupmember-uuid-2"]
}
```
## Observability
Log structured events for assignment mutations:
1. `exercise_assignment_updated`
2. `exercise_assignment_access_denied`
Fields:
- `courseId`, `exerciseId`, `mode`, `assignedCount`, `actorProfileId`, `viewerProfileId`
## Testing Strategy
### Unit Tests
1. Validation schema tests for mode/studentIds rules.
2. Query tests for assignment replacement and uniqueness behavior.
3. Access helper tests for all role/mode combinations.
### Integration/API Tests
1. Instructor can set selected students and read assignment back.
2. Unassigned student cannot fetch exercise detail.
3. Unassigned student cannot submit.
4. Assigned student can fetch and submit.
5. Course content for student excludes unassigned exercises.
6. LMS exercises list excludes unassigned exercises.
### Regression Tests
1. Existing all-students exercises behave exactly as before.
2. Non-student users still see full exercise list.
3. Analytics computations remain stable and assignment-aware.
## Rollout Plan
1. Ship DB schema first (safe default behavior).
2. Ship backend enforcement + new endpoints behind feature flag.
3. Ship instructor UI for selective assignment.
4. Enable feature for internal orgs, then broad rollout.
5. Monitor access-denied and assignment update logs.
## Risks and Mitigations
1. Risk: Assignment checks missed in one endpoint.
- Mitigation: centralize access helper and use integration tests per endpoint.
2. Risk: Analytics regressions due to denominator changes.
- Mitigation: snapshot tests for representative student datasets.
3. Risk: Large student lists in assignment selector.
- Mitigation: client-side search now; add paginated search endpoint if needed.
4. Risk: Confusion when removing assigned students who already submitted.
- Mitigation: show confirmation text before save and keep historical submissions.
## Open Questions
1. Should we support assigning by email before enrollment in v2?
2. Should assignment changes trigger student notifications?
3. Should instructors be able to "copy assignment set" across exercises?
## Definition of Done
1. Schema and migrations are merged and deploy cleanly.
2. Instructor can manage exercise audience from dashboard.
3. Student cannot see or submit unassigned exercises through any supported route.
4. Student progress and exercise analytics are assignment-aware.
5. Existing exercises with default mode show no behavioral regression.
6. API, DB, and dashboard builds pass.
@@ -0,0 +1,115 @@
# Selective Exercise Assignment Implementation Plan
## Scope Lock
1. Feature targets exercises only, not lessons.
2. Assignment is by enrolled student membership (`groupmember.id`), not by raw profile/email.
3. Exercise assignment modes are only `ALL_STUDENTS` and `SELECTED_STUDENTS`.
4. Assignment enforcement must run server-side for all student-facing exercise access paths.
5. Existing exercises stay globally visible (`ALL_STUDENTS`) unless explicitly changed.
## Delivery Phases
1. Phase A: DB schema + query foundations.
2. Phase B: API validation, services, and routes.
3. Phase C: Student visibility and analytics enforcement.
4. Phase D: Dashboard instructor UX.
5. Phase E: Testing, rollout, and hardening.
## Ticket Breakdown
| ID | Area | Task | Key Files | Depends On | Done When |
| --- | --- | --- | --- | --- | --- |
| SEA-A1 | DB Schema | Add `EXERCISE_ASSIGNMENT_MODE` enum and `exercise.assignment_mode` column | `packages/db/src/schema.ts` | None | Schema compiles and defaults to `ALL_STUDENTS` |
| SEA-A2 | DB Schema | Add `exercise_assignment` table + constraints + indexes | `packages/db/src/schema.ts` | SEA-A1 | Table has FK cascade and unique `(exercise_id, group_member_id)` |
| SEA-A3 | DB Migration | Generate migration SQL and metadata | `packages/db/src/migrations/*` | SEA-A1, SEA-A2 | Migration applies and rollback is validated |
| SEA-A4 | DB Queries | Add assignment CRUD helpers | `packages/db/src/queries/exercise/exercise.ts` (or new `assignment.ts`) | SEA-A2 | Replace/list/read helpers available with error logging pattern |
| SEA-A5 | DB Queries | Add shared access-check query helper for profile -> exercise | `packages/db/src/queries/exercise/*` | SEA-A4 | Helper returns role/groupmember/access decision reliably |
| SEA-B1 | Validation | Add assignment update schema | `packages/utils/src/validation/exercise/assignment.ts` + exports | SEA-A1 | Zod enforces non-empty studentIds for selected mode |
| SEA-B2 | API Errors | Add `EXERCISE_NOT_ASSIGNED` error code | `apps/api/src/utils/errors.ts` | None | Services can return specific denial code |
| SEA-B3 | API Service | Add `getExerciseAssignment` and `updateExerciseAssignment` service methods | `apps/api/src/services/exercise/exercise.ts` | SEA-A4, SEA-B1 | Team members can view/update assignment atomically |
| SEA-B4 | API Service | Add reusable `assertStudentExerciseAccess` guard | `apps/api/src/services/exercise/exercise.ts` | SEA-A5 | Guard can be called by detail/list/submission paths |
| SEA-B5 | API Route | Add `GET /course/:courseId/exercise/:exerciseId/assignment` | `apps/api/src/routes/course/exercise.ts` | SEA-B3 | Returns mode + assigned students |
| SEA-B6 | API Route | Add `PUT /course/:courseId/exercise/:exerciseId/assignment` | `apps/api/src/routes/course/exercise.ts` | SEA-B3 | Updates mode and assignment list |
| SEA-B7 | API Route | Enforce assignment guard on exercise detail endpoint | `apps/api/src/routes/course/exercise.ts` | SEA-B4 | Unassigned students blocked server-side |
| SEA-B8 | API Route | Enforce assignment guard on exercise submission endpoint | `apps/api/src/routes/course/exercise.ts` | SEA-B4 | Unassigned students cannot submit |
| SEA-C1 | Course Content | Filter unassigned exercises in course content fetch for students | `packages/db/src/queries/course/content.ts` | SEA-A5 | Student course payload excludes unassigned exercises |
| SEA-C2 | Exercise List | Filter unassigned exercises in list endpoint for students | `packages/db/src/queries/exercise/exercise.ts`, `apps/api/src/services/exercise/exercise.ts` | SEA-A5 | Student list endpoint returns only assigned exercises |
| SEA-C3 | LMS Exercises | Filter unassigned exercises in org LMS exercises query | `packages/db/src/queries/exercise/lms.ts` | SEA-A5 | Student LMS exercise page excludes unassigned items |
| SEA-C4 | Progress Query | Make `getCourseProgress` assignment-aware | `packages/db/src/queries/course/course.ts` | SEA-A5 | Denominator uses assigned exercise set |
| SEA-C5 | Analytics Query | Make `getUserExercisesStats` assignment-aware | `packages/db/src/queries/analytics/analytics.ts` | SEA-A5 | Stats only include assigned exercises |
| SEA-C6 | Analytics Query | Make `getProfileCourseProgress` assignment-aware | `packages/db/src/queries/analytics/analytics.ts` | SEA-A5 | Profile progress excludes unassigned exercises |
| SEA-D1 | Dashboard Types | Add request/response assignment types in course feature `utils/types.ts` | `apps/dashboard/src/lib/features/course/utils/types.ts` | SEA-B5, SEA-B6 | Types inferred from API RPC contract |
| SEA-D2 | Dashboard API | Add `getAssignment` + `updateAssignment` methods | `apps/dashboard/src/lib/features/course/api/exercise.svelte.ts` | SEA-D1 | API class handles loading/success/error states |
| SEA-D3 | Dashboard UI | Build instructor assignment panel component | `apps/dashboard/src/lib/features/course/components/exercise/exercise-assignment-panel.svelte` | SEA-D2 | Instructor can set mode + selected students |
| SEA-D4 | Dashboard UI | Integrate panel into exercise page (admin/tutor only) | `apps/dashboard/src/lib/features/course/pages/exercise.svelte` | SEA-D3 | Assignment controls visible and functional in exercise page |
| SEA-D5 | Translations | Add assignment-related translation keys | `apps/dashboard/src/lib/utils/translations/en.json` | SEA-D3 | No hardcoded user-facing copy |
| SEA-D6 | UX Hardening | Add warning modal when removing assigned students with submissions | assignment panel + API check | SEA-D3 | Instructor sees explicit warning before save |
| SEA-E1 | Tests | Add validation + query unit tests | `packages/utils`, `packages/db` test locations | SEA-A4, SEA-B1 | Core rules and DB helper behavior covered |
| SEA-E2 | Tests | Add API integration tests for access enforcement | `apps/api` tests | SEA-B7, SEA-B8, SEA-C1 | Student access blocked consistently |
| SEA-E3 | Tests | Add regression tests for all-students default behavior | API + query tests | SEA-B8 | Existing flow unchanged when mode is default |
| SEA-E4 | Rollout | Add feature flag (optional), staged enable checklist, and monitoring hooks | API config/docs | SEA-E2 | Safe staged rollout documented and executable |
| SEA-E5 | Verification | Run cross-package builds and smoke checks | workspace commands | SEA-D6 | `@cio/db`, `@cio/utils`, `@cio/api`, `@cio/dashboard` build clean |
## API Contract Targets
### Protected Course Routes
1. `GET /course/:courseId/exercise/:exerciseId/assignment`
2. `PUT /course/:courseId/exercise/:exerciseId/assignment`
### Updated Protected Behavior
1. `GET /course/:courseId/exercise` returns assigned-only list for student viewers.
2. `GET /course/:courseId/exercise/:exerciseId` enforces assignment for student viewers.
3. `POST /course/:courseId/exercise/:exerciseId/submission` enforces assignment for student viewers.
## Acceptance Criteria by Phase
### Phase A
1. Migration adds new enum, column, and join table.
2. Existing exercises resolve to `ALL_STUDENTS`.
3. Query helper API is available for assignment set replacement and access checks.
### Phase B
1. Team members can fetch and update assignment state via new endpoints.
2. Validation rejects `SELECTED_STUDENTS` with empty student list.
3. API returns consistent error code for unassigned access.
### Phase C
1. Student course content payload excludes unassigned exercises.
2. Student LMS exercises feed excludes unassigned exercises.
3. Student progress/analytics denominator excludes unassigned exercises.
4. Non-student viewers still see all exercises.
### Phase D
1. Instructor UI allows mode switch and student selection.
2. UI uses translation keys and existing API class patterns.
3. Save flow is explicit and resilient to validation failures.
### Phase E
1. Automated tests cover access rules and regression paths.
2. Rollout checklist is documented.
3. Build verification passes across affected packages.
## Verification Commands
1. `pnpm --filter @cio/db build`
2. `pnpm --filter @cio/utils build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/dashboard build`
5. Run targeted tests for DB queries and API routes.
## Suggested First Sprint Slice
1. SEA-A1, SEA-A2, SEA-A3, SEA-A4
2. SEA-B1, SEA-B2, SEA-B3, SEA-B4
3. SEA-B7, SEA-B8
4. SEA-C1
This delivers schema + core enforcement quickly so student access correctness is solved before UI polish.
+42
View File
@@ -0,0 +1,42 @@
# Self-Hosted Single-Org + Invite-Only Signup
## Purpose
Ensure self-hosted instances support only one organization, auto-enroll new users as students, and restrict signups to invite-only when configured by org admins.
## Goals
- Enforce a single organization per self-hosted instance.
- Automatically add any non-member user as a student in the self-hosted org.
- Use a single domain for dashboard + LMS in self-hosted mode.
- Allow admins to disable public signups and enforce invite-only access.
## Constraints
- First signup can create the only org; subsequent org creation is blocked.
- Users without membership are treated as students and routed to `/lms`.
- Students cannot access `/org/[slug]` routes.
- Org switching UI is hidden in self-hosted mode.
## Backend Changes
- Add `PUBLIC_IS_SELFHOSTED` to API env schema.
- Add an org count/first-org query to detect the singleton org.
- In `createOrganizationWithOwner` and `createOrg`, block org creation when self-hosted and an org exists.
- In the account bootstrap (`GET /account`), auto-add user as `ROLE.STUDENT` to the single org if they are not a member.
## Frontend Changes
- In `getOrgSiteInfo`, when self-hosted, treat the root domain as the org site by returning the single org even without subdomain.
- In app init routing, if self-hosted and user has no orgs, route to `/lms`.
- Hide org switching and create-org UI in self-hosted mode.
## Invite-Only Signup Setting
- Add `settings.signup.inviteOnly` to `organization.settings` and allow updates via `ZUpdateOrganization`.
- Add a toggle in `org/[slug]/settings/org` labeled “Allow public signups” with a description.
- Hide signup link/buttons in Auth UI and navigation when invite-only is enabled.
- Only allow signups when invite context exists:
- `invite_token` query param (course invite)
- `redirect` contains `/invite/t/` (org invite)
- `redirect` contains `invite_token` (course invite redirect)
- If invite-only and no invite context, show a message and hide the signup form.
- Enforce invite-only on the backend to block non-invite signup attempts.
## Open UX Copy
- Signup message when invite-only and no invite context.
- Description text for “Allow public signups” toggle.
+464
View File
@@ -0,0 +1,464 @@
# Scalable Tags Feature PRD (Classroomio)
## Purpose
Define a scalable tagging system for Classroomio that improves course discoverability, organization, and personalization across admin, teacher, and student experiences.
## Problem Statement
Classroomio currently supports course type labels (`LIVE_CLASS`, `SELF_PACED`) but does not have a reusable, organization-scoped tagging model for catalog management and discovery.
This creates gaps:
- Course catalogs become harder to browse as organizations scale.
- Students cannot filter learning paths by topic/level/format.
- Teachers cannot consistently classify content.
- Analytics cannot answer questions like “which topics drive enrollments/completion?”
- Tag usage is not standardized across features (courses, explore, community, media, quiz).
## Product Vision
Tags should become a lightweight taxonomy layer that powers:
- Better organization for educators.
- Faster discovery for learners.
- Reusable filtering primitives for every major content surface.
- Future recommendation and analytics models.
Hard requirement:
- Tags are strictly organization-scoped and cannot be shared across organizations.
## Goals
1. Introduce organization-scoped tags with governance controls.
2. Support course tagging in v1 with grouped/faceted filtering for scalable discovery.
3. Add role-aware workflows for admin, teacher, and student.
4. Integrate tags into existing Classroomio surfaces without breaking current flows.
5. Keep query performance strong for large catalogs.
## Confirmed Decisions
1. Only `ROLE.ADMIN` can create, edit, archive, and restore tags.
2. Students are filter-only in v1; event tracking should still lay groundwork for future personalization.
3. Invite/email campaign integrations with tags are out of scope for v1.
4. Grouped/faceted tags are required (not flat-only tags).
5. Every tag must belong to a group; create a default `General` group for fallback.
6. For `SINGLE_SELECT` groups, assigning a new tag auto-replaces the existing tag in that group.
7. On public `/courses`, admins control which groups appear as public filters.
## Non-Goals (v1)
- AI-generated tags.
- Cross-organization/global tag sharing.
- Full recommendation engine rewrite.
- Free-form student-created tags.
- Migration of every entity type in the first release.
- Invite/email campaign integrations using tags.
## Current-State Audit
Legend: `available now`, `partial`, `missing`
| Capability | Availability | Notes |
| --- | --- | --- |
| Course type labels | partial | Present in UI as course-type badges, but not taxonomy tags |
| Org-level tag management | missing | No CRUD for tags |
| Course-tag assignment | missing | No first-class relation in DB/API |
| Tag filtering in org courses page | missing | Current filters are date/published/lessons only |
| Tag filtering in LMS explore/mylearning | missing | Search/sort only |
| Public org `/courses` catalog with tag filters | missing | No dedicated org-wide public catalog route today |
| Grouped/faceted tag filtering | missing | No taxonomy/group model for ecommerce-style filters |
| Tag analytics | missing | No usage or performance by tag |
| Tag-based API query params | missing | Existing course endpoints return untagged data |
## Data Sources Checked
- `apps/website/src/routes/roadmap/+page.svelte`
- `packages/db/src/schema.ts`
- `apps/api/src/routes/organization/organization.ts`
- `apps/api/src/services/organization.ts`
- `packages/db/src/queries/course/course.ts`
- `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte`
- `apps/dashboard/src/lib/features/course/pages/courses.svelte`
- `apps/dashboard/src/lib/features/lms/pages/explore.svelte`
- `apps/dashboard/src/lib/features/lms/pages/mylearning.svelte`
- `packages/utils/src/constants/roles.ts`
## Personas and Permissions
### Admin (`ROLE.ADMIN`)
- Create, update, archive, restore tags.
- Set tag metadata (name, slug, color, visibility).
- Configure governance limits.
- Bulk assign/unassign tags across courses.
- View tag analytics and audit history.
### Teacher (`ROLE.TUTOR`)
- Assign/unassign existing tags on courses they manage.
- Search/select existing tags in course settings and course creation flows.
- Cannot create/update/archive tags in v1.
### Student (`ROLE.STUDENT`)
- View tags on course cards and course pages.
- Filter browse/discovery pages by tags.
- Use tag filters in `Explore` and `My Learning`.
- No create/update/delete permissions.
## Functional Requirements
### 0. Organization Isolation (Hard Requirement)
- Every tag must belong to exactly one `organization_id`.
- Tag assignment is valid only when the tag and target entity belong to the same organization.
- No cross-organization tag reuse, lookup, or assignment is allowed.
### 1. Tag Lifecycle (Org Scope)
- Only `ROLE.ADMIN` can create, update, archive, and restore tags.
- Create tag with:
- `name` (required)
- `slug` (auto-generated + editable)
- `description` (optional)
- `color` (required visual token selected from 10-color popover palette)
- `visibility` (`PUBLIC` or `INTERNAL`)
- Prevent duplicates by normalized name/slug within an organization.
- Archive tags instead of hard-delete to avoid breaking history.
- Restore archived tags.
Color rule:
- Tag color must be one of the predefined v1 palette values (exactly 10 options).
### 2. Tag Grouping (Faceted Taxonomy)
- Introduce organization-scoped tag groups (facets) to support ecommerce-style filtering.
- Every tag belongs to one group.
- Example starter groups:
- `Learning Difficulty`: `beginner`, `intermediate`, `advanced`
- `Technologies`: `react`, `svelte`, `vue`
- `Job Offering`: `product-management`, `developer`, `qa`
- Group filter mode:
- `SINGLE_SELECT`: only one tag in that group per course (example: Difficulty)
- `MULTI_SELECT`: multiple tags in that group per course (example: Technologies)
- Filter semantics:
- OR within a group (if multiple values selected in same group)
- AND across groups
### 3. Tag Assignment (v1 Target: Courses)
- Assign up to configurable max tags per course (default: 20).
- Unassign tags without deleting tag definitions.
- Support bulk assignment/unassignment for admins in org courses view.
- Render assigned tags in course cards and list rows.
- Enforce group rules on assignment (for `SINGLE_SELECT`, auto-replace existing conflicting tag in the same group).
### 4. Discovery and Filtering
- Filter org course catalog by one or more tags.
- Filter LMS Explore and My Learning by tags.
- Support combined filters: `search + tags + existing sort`.
- Expose tag chips on course details and public course landing.
- Add public org course catalog route: `/courses` (on org site/custom domain) with faceted tag filtering.
- In admin tags table, `Total Courses` per tag is clickable and deep-links to admin courses with pre-applied tag query filter.
### 5. Governance and Quality
- Configurable limits per org:
- max number of active tags (default: 500)
- max number of tag groups (default: 25)
- max tags per course (default: 20)
- Tag usage count and “unused tags” visibility.
- Audit events for admin-level lifecycle actions.
### 6. Analytics
- Tag usage metrics:
- number of tagged courses
- enrollments by tag
- completion rate by tag
- Trend-ready event stream for tag assignment and filter usage.
- Persist filter interaction events as foundation for future personalization (students still filter-only in v1).
## Taxonomy Clarification
Taxonomy in this PRD means a structured way to classify courses, not just a flat tag list.
- Flat tags: all tags in one pool, harder to filter at scale.
- Grouped taxonomy (facets): tags are organized into groups like `Difficulty`, `Technology`, `Job Offering`.
Why grouped taxonomy is useful:
- Better UX: users can filter like ecommerce (`Difficulty=Beginner`, `Technology=React`).
- Better data quality: prevents mixed/duplicate meaning tags.
- Better analytics: compare performance by consistent dimensions.
## Integration Map
| Surface | Integration | Role Impact |
| --- | --- | --- |
| Org sidebar | Add top-level `Tags` item (same level as `Courses`, `Media`) -> `/org/[slug]/tags` | Admin/Teacher |
| `org/[slug]/courses` | Multi-select tag filter + bulk tagging actions | Admin/Teacher |
| `courses/[id]/settings` | Tag assignment editor in course settings | Admin/Teacher |
| New course modal | Optional tag selection during creation | Admin |
| `lms/explore` | Tag chips + multi-select tag filtering | Student |
| `lms/mylearning` | Filter enrolled courses by tags | Student |
| Public org catalog `/courses` | Faceted filtering (grouped tags) across published courses | Student/Public |
| Public course page (`/course/[slug]`) | Display public tags | Student/Public |
| Community (future phase) | Optional tag alignment for posts/questions | Admin/Teacher/Student |
| Media Manager (future phase) | Tag assets for reuse/search | Admin/Teacher |
| Quiz (future phase) | Tag quizzes by topic/outcome | Admin/Teacher |
## UX Flows
### Admin Flow
1. Navigate to `Org -> Tags` (`/org/[slug]/tags`).
2. Create a tag (`Data Science`, `Beginner`, `Project-Based`, etc).
3. Set visibility and choose tag color from 10-color popover swatch selector.
4. Assign tags from:
- Course settings (single course).
- Org courses page (bulk action).
5. Click a tag’s `Total Courses` count to open admin courses filtered by that tag.
6. Open tag analytics panel to monitor usage and performance.
7. Archive stale tags and resolve low-quality duplicates.
### Teacher Flow
1. Open a managed course in `courses/[id]/settings`.
2. Use tag selector to add/remove approved tags.
3. Save course updates.
4. Confirm tags appear in org catalog and student discovery pages.
### Student Flow
1. Open `LMS Explore`.
2. See featured/popular tags as quick filter chips.
3. Select one or more tags to narrow course cards.
4. Combine tags with text search.
5. Open `My Learning` and apply tag filters to focus in-progress/completed courses.
6. On org public site, use `/courses` faceted filters to browse all published courses by grouped tags.
## UX States and Rules
- Empty state (no tags in org): show admin CTA to create first tag.
- Empty state (student filter no results): clear message + “reset filters”.
- Archived tag behavior:
- visible in historical views as muted chip.
- hidden from new assignment by default.
- Validation feedback:
- duplicate tag names/slugs.
- max tags per course.
- forbidden characters in slug.
## Technical Design (Proposed)
## Data Model
### `tag_group`
- `id` (uuid, pk)
- `organization_id` (uuid, fk, indexed)
- `name` (text, required)
- `slug` (text, required)
- `description` (text, nullable)
- `filter_mode` (`SINGLE_SELECT` | `MULTI_SELECT`)
- `is_public_filterable` (boolean, default `true`)
- `display_order` (int, default 0)
- `status` (`ACTIVE` | `ARCHIVED`)
- `created_by_profile_id` (uuid)
- `updated_by_profile_id` (uuid)
- `created_at`, `updated_at`
Constraints and indexes:
- unique `(organization_id, slug)`
- normalized unique on `(organization_id, lower(name))`
- index `(organization_id, status, display_order)`
### `tag`
- `id` (uuid, pk)
- `organization_id` (uuid, fk, indexed)
- `group_id` (uuid, fk to `tag_group.id`, indexed)
- `name` (text, required)
- `slug` (text, required)
- `description` (text, nullable)
- `color` (text, required hex value from fixed 10-color palette)
- `visibility` (`PUBLIC` | `INTERNAL`)
- `status` (`ACTIVE` | `ARCHIVED`)
- `usage_count` (int, default 0)
- `created_by_profile_id` (uuid)
- `updated_by_profile_id` (uuid)
- `created_at`, `updated_at`
Rule:
- `group_id` is required for all tags. No ungrouped tags.
Constraints and indexes:
- unique `(organization_id, slug)`
- normalized unique on `(organization_id, lower(name))`
- unique `(organization_id, group_id, slug)`
- index `(organization_id, status)`
- index `(organization_id, usage_count desc)`
### `tag_assignment`
- `id` (uuid, pk)
- `organization_id` (uuid, indexed)
- `tag_id` (uuid, fk to `tag.id`, indexed)
- `target_type` (`course` in v1; extensible enum later)
- `target_id` (uuid)
- `assigned_by_profile_id` (uuid)
- `source` (`manual` | `bulk` | `api`)
- `created_at`
Constraints and indexes:
- unique `(tag_id, target_type, target_id)`
- index `(organization_id, target_type, target_id)`
- index `(organization_id, target_type, tag_id)`
- enforce same-org integrity in service/query checks:
- `tag.group_id` must belong to same `organization_id`
- `tag_assignment.organization_id` must match `tag.organization_id`
- target entity organization must equal `tag_assignment.organization_id`
- for `SINGLE_SELECT` groups, only one tag assignment per `(target_type, target_id, group_id)`
## Validation Layer
- `packages/utils/src/validation/tag/tag.ts`
- `ZCreateTagGroup`
- `ZUpdateTagGroup`
- `ZListTagGroupsQuery`
- `ZCreateTag`
- `ZUpdateTag`
- `ZListTagsQuery`
- `ZAssignCourseTags`
- `ZBulkAssignCourseTags`
## Query Layer
- `packages/db/src/queries/tag/tag.ts`
- `createTagGroup`
- `updateTagGroup`
- `archiveTagGroup`
- `listTagGroupsByOrg`
- `createTag`
- `updateTag`
- `archiveTag`
- `restoreTag`
- `listTagsByOrg`
- `assignTagsToCourse`
- `setCourseTags`
- `listCourseTags`
- `getTagUsageSummary`
## Service Layer
- `apps/api/src/services/tag.ts`
- `createOrganizationTagGroupService`
- `updateOrganizationTagGroupService`
- `archiveOrganizationTagGroupService`
- `createOrganizationTagService`
- `updateOrganizationTagService`
- `archiveOrganizationTagService`
- `setCourseTagsService`
- `bulkAssignCourseTagsService`
- `getOrganizationTagAnalyticsService`
## Route Layer
- `apps/api/src/routes/tag/tag.ts`
- `GET /organization/tag-groups`
- `POST /organization/tag-groups`
- `PUT /organization/tag-groups/:groupId`
- `DELETE /organization/tag-groups/:groupId` (archive)
- `GET /organization/tags`
- `POST /organization/tags`
- `PUT /organization/tags/:tagId`
- `DELETE /organization/tags/:tagId` (archive)
- `POST /organization/tags/:tagId/restore`
- `GET /course/:courseId/tags`
- `PUT /course/:courseId/tags`
Auth/authorization:
- Tag lifecycle endpoints: `authMiddleware + orgAdminMiddleware`
- Course tag assignment: `authMiddleware + courseTeamMemberMiddleware`
- Student read/filter endpoints: `authMiddleware + orgMemberMiddleware` or existing public route behavior
- Public catalog filtering: extend existing `GET /organization/courses/public` query with grouped tag filters.
## Frontend Plan
### Feature module
- `apps/dashboard/src/lib/features/tag/`
- `api/tag.svelte.ts`
- `utils/types.ts`
- `components/tag-manager.svelte`
- `components/tag-selector.svelte`
- `components/tag-chip.svelte`
- `components/tag-filter-bar.svelte`
### Route additions
- `apps/dashboard/src/routes/org/[slug]/tags/+page.server.ts`
- `apps/dashboard/src/routes/org/[slug]/tags/+page.svelte`
- Public catalog route (organization site/custom domain): `/courses`
### Navigation
- Add top-level org sidebar item for tags:
- update `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`
- path: `/tags`
### Existing page integration
- `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte`
- `apps/dashboard/src/lib/features/course/pages/courses.svelte`
- `apps/dashboard/src/lib/features/lms/pages/explore.svelte`
- `apps/dashboard/src/lib/features/lms/pages/mylearning.svelte`
- `apps/dashboard/src/lib/features/course/pages/settings.svelte`
- Public org courses page with faceted filters and grouped chips.
### UI/Copy rules
- Add translation keys in `apps/dashboard/src/lib/utils/translations/en.json`.
- No hardcoded user-facing strings.
## Scalability Requirements
- Support organizations with:
- 1,000+ courses
- 500+ tags
- high filter read volume from LMS views
- Response targets:
- tag list endpoint P95 < 250ms
- course list with tag filters P95 < 400ms
- Pagination for tag list and analytics endpoints.
- Query plans must rely on org-scoped indexes.
- Cache tag dictionary per org in app layer to reduce repeated reads.
## Security and Data Isolation
- Strict org scoping on all tag and assignment queries.
- Validate target ownership before assignment (course must belong to org).
- Reject any cross-org assignment attempt, even if tag ID is valid.
- Audit logging for admin lifecycle actions.
- Rate-limit bulk operations.
## Rollout Plan
### Phase 0: Foundation
1. Schema + migrations (`tag_group`, `tag`, `tag_assignment`).
2. Validation, queries, services, routes for groups and tags.
3. Minimal assignment support on course settings.
### Phase 1: Core UX
1. Org sidebar tags manager page (`/org/[slug]/tags`).
2. Org courses tag filter and bulk assignment.
3. LMS Explore/My Learning tag filtering.
4. Public `/courses` catalog with faceted grouped filtering.
5. Tag rendering on course cards/public pages.
### Phase 2: Analytics and Governance
1. Tag usage/performance dashboard.
2. Audit history.
3. Org-level governance settings and limits.
### Phase 3: Cross-Feature Expansion
1. Community content tagging.
2. Media/quiz tagging.
3. Optional rule-based and recommendation integrations.
## Success Metrics
- >= 70% of active orgs create at least 5 tags within 60 days.
- >= 60% of org courses tagged within 90 days.
- >= 30% of LMS Explore sessions use at least one tag filter.
- Improved course discovery:
- reduced time-to-first-course-open in Explore.
- increased enroll conversion from Explore.
## Risks and Mitigations
- Risk: Tag sprawl/low-quality taxonomy.
- Mitigation: admin governance, archive flow, usage stats.
- Risk: Query slowdown on large catalogs.
- Mitigation: scoped indexes, pagination, caching, P95 SLO tracking.
- Risk: Permission confusion between admin and tutor.
- Mitigation: explicit capability matrix in UI and endpoint guards.
- Risk: Inconsistent copy and UX patterns.
- Mitigation: shared `tag` feature components + translation keys.
## Open Questions
None for v1 scope.
## Kanban (Ideation)
| Ticket | Title | Todo | In Progress | Verification | Done |
| --- | --- | --- | --- | --- | --- |
| TG-1 | DB schema for tags and assignments | [ ] | [ ] | [ ] | [ ] |
| TG-2 | Validation + query layer | [ ] | [ ] | [ ] | [ ] |
| TG-3 | API routes and service guards | [ ] | [ ] | [ ] | [ ] |
| TG-4 | Org sidebar tags page UI (`/org/[slug]/tags`) | [ ] | [ ] | [ ] | [ ] |
| TG-5 | Course settings assignment UI | [ ] | [ ] | [ ] | [ ] |
| TG-6 | Org/LMS filtering integration | [ ] | [ ] | [ ] | [ ] |
| TG-7 | Analytics and governance | [ ] | [ ] | [ ] | [ ] |
@@ -0,0 +1,149 @@
# Tags Feature Implementation Plan (v1)
## Scope Lock (From Product Decisions)
1. Tags are organization-scoped only (`organization_id` required everywhere).
2. Tag lifecycle (create/edit/archive/restore) is `ROLE.ADMIN` only.
3. Teachers can assign/unassign existing tags on managed courses.
4. Students are filter-only in v1.
5. Grouped/faceted tags are required.
6. Every tag must belong to a group (default fallback group: `General`).
7. `SINGLE_SELECT` groups auto-replace conflicting tag assignments.
8. Public org `/courses` route must support grouped tag filtering.
9. Public filter groups are admin-controlled.
## Delivery Phases
1. Phase A: Data model + validation + DB queries.
2. Phase B: API services/routes + existing endpoint extensions.
3. Phase C: Admin dashboard (tag groups/tags management + course tagging).
4. Phase D: Student discovery integration (LMS + public `/courses`).
5. Phase E: Analytics events + hardening + rollout.
## Ticket Breakdown
| ID | Area | Task | Key Files | Dependencies | Done When |
| --- | --- | --- | --- | --- | --- |
| TG-A1 | DB | Add `tag_group` schema | `packages/db/src/schema.ts` | None | Table has org scoping, status, filter mode, public flag, indexes |
| TG-A2 | DB | Add `tag` schema with required `group_id` | `packages/db/src/schema.ts` | TG-A1 | Tag row cannot exist without group; org constraints enforced |
| TG-A3 | DB | Add `tag_assignment` schema | `packages/db/src/schema.ts` | TG-A2 | Unique and indexed assignment table exists |
| TG-A4 | DB | Add DB constraints for same-org integrity | migration + schema constraints | TG-A1,TG-A2,TG-A3 | Cross-org assignment impossible at DB/service level |
| TG-A5 | DB | Add `SINGLE_SELECT` enforcement strategy | query/service logic (+ optional constraint helper) | TG-A3 | New assignment replaces conflicting assignment in same group |
| TG-A6 | DB | Seed default `General` group per org (on-demand or migration-safe bootstrap) | `packages/db/src/scripts/*` or service bootstrap | TG-A1 | Existing orgs can create/use tags immediately without missing group errors |
| TG-A7 | Utils | Add validation schemas for groups/tags/filter params (including fixed 10-color palette validation) | `packages/utils/src/validation/tag/tag.ts` + exports | None | Zod schemas cover CRUD + filter query + assignment payloads and restrict tag color to allowed palette |
| TG-A8 | DB Queries | Implement group query module | `packages/db/src/queries/tag/tag.ts` | TG-A1,TG-A7 | CRUD/list/archive for tag groups works |
| TG-A9 | DB Queries | Implement tag query module | `packages/db/src/queries/tag/tag.ts` | TG-A2,TG-A7 | CRUD/list/archive for tags works |
| TG-A10 | DB Queries | Implement assignment query module | `packages/db/src/queries/tag/tag.ts` | TG-A3,TG-A5 | Course tag set/replace/list works with group rules |
| TG-B1 | API | Add tag services | `apps/api/src/services/tag.ts` | TG-A8,TG-A9,TG-A10 | Service layer enforces role and org policies |
| TG-B2 | API | Add routes under organization/course | `apps/api/src/routes/tag/tag.ts` + index exports | TG-B1 | Endpoints compile and respond with standard success/error shape |
| TG-B3 | API | Register tag routes in app/router tree | `apps/api/src/routes/organization/index.ts`, `apps/api/src/app.ts` | TG-B2 | Routes are reachable from RPC client |
| TG-B4 | API | Extend `GET /organization/courses/public` with grouped tag filters | `apps/api/src/routes/organization/organization.ts`, service/query layer | TG-B1 | Public published courses can be filtered by grouped tags |
| TG-B5 | API | Extend org/LMS course list endpoints to return tags/group metadata needed by UI | `apps/api/src/services/organization.ts`, `packages/db/src/queries/course/course.ts` | TG-A10 | Org courses/explore/mylearning payloads include tag context |
| TG-B6 | API | Add analytics event hooks for filter usage and tag assignment | services where filters/assignments are handled | TG-B1 | Events captured for future personalization baseline |
| TG-C1 | Dashboard Types | Add tag feature request/response types | `apps/dashboard/src/lib/features/tag/utils/types.ts` | TG-B2 | Strongly typed RPC request/response types available |
| TG-C2 | Dashboard API | Add tag API class | `apps/dashboard/src/lib/features/tag/api/tag.svelte.ts` | TG-C1 | CRUD + assign + filter helpers available |
| TG-C3 | Admin UI | Add top-level org tags page + create/edit modal with 10-color popover swatch selector | `apps/dashboard/src/routes/org/[slug]/tags/+page.server.ts`, `+page.svelte` | TG-C2 | Admin can manage groups/tags from UI and select only allowed palette colors |
| TG-C4 | Navigation | Add Tags entry to org sidebar navigation (same level as Courses/Media) | `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` | TG-C3 | Tags page discoverable in org sidebar |
| TG-C5 | Course Settings | Add tag selector in course settings | `apps/dashboard/src/lib/features/course/pages/settings.svelte` + shared tag component | TG-C2 | Admin/teacher can assign/unassign tags on a course |
| TG-C6 | Admin Tags Table | Add `Total Courses` deep-link column | admin tags page/components | TG-C3,TG-B5 | Clicking count routes to `/org/[slug]/courses?tags=<tagSlug>` |
| TG-C7 | Org Courses | Add grouped tag filters, URL hydration, and bulk actions in org courses view | `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte`, `apps/dashboard/src/lib/features/course/pages/courses.svelte` | TG-C5,TG-C6 | Catalog filters by groups/tags, supports bulk assignment, and honors `tags` query param on load |
| TG-D1 | LMS Explore | Add grouped tag filtering in explore | `apps/dashboard/src/lib/features/lms/pages/explore.svelte` | TG-B5 | Student can filter explore results by facet groups |
| TG-D2 | LMS My Learning | Add grouped tag filtering in my learning | `apps/dashboard/src/lib/features/lms/pages/mylearning.svelte` | TG-B5 | Student can filter enrolled/in-progress/completed by tags |
| TG-D3 | Public Route | Add public org `/courses` route with faceted filters | new route files under `apps/dashboard/src/routes/courses/+*` or org-site-aware equivalent | TG-B4 | Public org catalog exists and filters published courses by groups/tags |
| TG-D4 | Landing Integration | Add link from landing page courses section to `/courses` | `apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte` | TG-D3 | “View all courses” navigates to filterable `/courses` catalog |
| TG-E1 | Copy/Translations | Add i18n keys for tag/group/filter UI | `apps/dashboard/src/lib/utils/translations/en.json` (+ other locales later) | TG-C3 | No hardcoded strings in new UI |
| TG-E2 | Observability | Add logs/metrics for failed assignments and filter query performance | API services/routes | TG-B2 | Error + perf signals visible in logs/monitoring |
| TG-E3 | Access Tests | Add auth/role tests for admin-only lifecycle and teacher assignment | API tests | TG-B2 | Unauthorized role actions return expected 403 |
| TG-E4 | Regression Tests | Add query/service tests for org isolation and single-select replacement | DB/API tests | TG-A10,TG-B1 | Cross-org and group conflict regressions prevented |
| TG-E5 | Rollout | Feature flag and staged rollout plan | env/config + UI guards | TG-D3 | Can enable per environment with rollback path |
## API Contract Additions (Target)
### Tag Groups
1. `GET /organization/tag-groups`
2. `POST /organization/tag-groups`
3. `PUT /organization/tag-groups/:groupId`
4. `DELETE /organization/tag-groups/:groupId`
### Tags
1. `GET /organization/tags`
2. `POST /organization/tags`
3. `PUT /organization/tags/:tagId`
4. `DELETE /organization/tags/:tagId`
5. `POST /organization/tags/:tagId/restore`
### Course Tag Assignment
1. `GET /course/:courseId/tags`
2. `PUT /course/:courseId/tags`
### Public Course Catalog Filtering
1. Extend `GET /organization/courses/public` query:
- `groupSlug[]`
- `tagSlug[]`
- or grouped map style (`filters[difficulty]=beginner,advanced`)
## Public `/courses` Route Notes
1. Route should be org-site aware (same host behavior as current landing page and `/course/[slug]` flow).
2. Only published and active courses are shown.
3. Only groups with `is_public_filterable=true` are displayed as filter facets.
4. `INTERNAL` tags are never shown publicly.
5. URL query params should reflect selected filters for shareable links.
## Data/Behavior Rules to Enforce
1. `tag.group_id` is mandatory.
2. All group/tag/assignment writes must check org ownership.
3. Teacher actions:
- allowed: assign/unassign existing tags on managed courses.
- not allowed: group/tag lifecycle changes.
4. `SINGLE_SELECT` conflict resolution:
- Replace old assignment with new one in same group and target.
5. Archive behavior:
- archived groups/tags are not assignable.
- historical rendering is allowed with muted appearance.
6. Admin tags deep-link behavior:
- `Total Courses` click on a tag navigates to `/org/[slug]/courses?tags=<tagSlug>`.
- courses page hydrates query and applies tag filter automatically.
7. Tag color rule:
- create/edit must use popover swatch selector with exactly 10 palette colors.
- API validation rejects colors outside the allowed palette.
## Acceptance Criteria by Phase
### Phase A Accept
1. Migration applies cleanly.
2. Existing orgs can create first tag without manual DB intervention.
3. Cross-org assignment attempts fail.
### Phase B Accept
1. Admin lifecycle endpoints enforce `orgAdminMiddleware`.
2. Teacher cannot create/edit/archive tags or groups.
3. Course assignment endpoint supports teacher (course team member) flow.
### Phase C Accept
1. Admin can create groups and tags from `/org/[slug]/tags`.
2. Course settings can assign tags with single-select auto-replacement.
3. Admin tags table shows clickable `Total Courses` per tag.
4. Clicking `Total Courses` opens admin courses with `tags` query param applied.
5. Org courses page can filter by grouped facets and hydrated URL query filters.
### Phase D Accept
1. LMS Explore and My Learning both support grouped filters.
2. Public `/courses` page exists and supports faceted filtering.
3. Public filters hide non-public groups and `INTERNAL` tags.
### Phase E Accept
1. Filter usage events are recorded.
2. P95 latency targets for tag endpoints and filtered catalogs are met.
3. Rollback path documented and tested.
## Verification Commands
1. `pnpm --filter @cio/db build`
2. `pnpm --filter @cio/utils build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/dashboard build`
5. Run relevant unit/integration tests for DB queries and API routes.
## Suggested First Sprint Slice
1. TG-A1 to TG-A10
2. TG-B1 to TG-B3
3. TG-C1 to TG-C5
This first slice delivers admin-managed groups/tags and course-level assignment in dashboard, with strict org isolation and role permissions.
+293
View File
@@ -0,0 +1,293 @@
# Tags UI Format Spec (v1)
## Goal
Define how the tags UI should be structured and formatted across admin, teacher, and student flows.
This spec focuses on layout, information hierarchy, and interaction design, not backend behavior.
## Global UI Principles
1. Keep filters visible and understandable at a glance.
2. Use grouped facets (not flat tag cloud) as the primary mental model.
3. Make selected filters persistent and removable from a single “active filters” region.
4. Preserve existing Classroomio page shell patterns (`Page.Root`, `Page.Header`, `Page.Body`).
5. Ensure mobile-first usability with drawer/sheet patterns for filters.
## Shared Component Formatting
### Tag Chip
1. Shape: rounded, compact (`h-7` to `h-8`), no truncation until > 24 chars.
2. Variants:
- `default`: active/public tag.
- `muted`: archived/internal/historical render.
- `outline`: inactive selectable option.
3. Content:
- optional color dot.
- label text.
- optional remove icon for selected filters only.
### Facet Group Block
1. Title row:
- group name (for example `Learning Difficulty`).
- metadata hint (`Single Select` or `Multi Select`).
2. Body:
- `SINGLE_SELECT`: radio-like segmented options.
- `MULTI_SELECT`: chip/toggle grid with wrap.
3. Footer:
- optional “clear group” action.
### Active Filters Bar
1. Location: directly below search/sort row.
2. Content:
- `X filters active` counter.
- list of selected chips.
- `Clear all` action.
3. Behavior: sticky in long scroll contexts (org courses/public courses).
## Screen-by-Screen Format
## 1) Admin Tags Page (`/org/[slug]/tags`)
This section is the implementation contract for the admin page.
### Sidebar Requirement
1. Tags must be a top-level org sidebar item.
2. Placement should be alongside core org items (same level as `Courses`, `Media`, etc), not nested under settings.
### Required Page Structure
1. Use existing dashboard page shell:
- `Page.Root`
- `Page.Header`
- `Page.HeaderContent`
- `Page.Action`
- `Page.BodyHeader`
- `Page.Body`
2. Keep title in header as `Tags`.
3. Use `Page.BodyHeader align="right"` for top-of-table controls (search/filter).
### Required Header Action Pattern (Storybook-aligned)
1. Use `ButtonGroup + DropdownMenu` for creation actions (matching Storybook patterns in:
- `packages/storybook/src/molecules/page/page.stories.svelte`
- `packages/storybook/src/molecules/button-group/button-group.stories.svelte`)
2. Header action format:
- Primary button label: `Create`
- Adjacent dropdown trigger button (chevron icon)
- Dropdown items:
- `Create Tag Group`
- `Create Tag`
### Required Table Format
1. Use `@cio/ui/base/table` components (table style, not card grid).
2. Display groups as section headers in table:
- Group row appears first.
- Tag rows render as sub-items under each group.
3. Group header row should include:
- Group name
- Group mode badge (`Single`/`Multi`)
- Optional group-level metadata (`Public Filter`)
### Required Tag Row Format
1. Left-most cell contains:
- circle color badge (`rounded-full`) using tag color
- tag name next to badge
2. Include columns for:
- `Tag`
- `Total Courses` (second column, required)
- `Description`
- `Category` (group name)
- `Visibility`
- `Usage`
- `Actions`
3. `Total Courses` cell behavior:
- render count as a clickable control (link/button style).
- on click, navigate to admin courses page with tag query param:
- `/org/[slug]/courses?tags=<tagSlug>`
- support comma-separated format for future multi-tag deep links:
- `/org/[slug]/courses?tags=<tagSlugA>,<tagSlugB>`
4. Each row must include a `secondary` button for edit:
- use `Button variant="secondary"`
- label: `Edit`
- edits: tag name, description, category
### Required Edit Interaction
1. `Edit` (`secondary` button) opens edit UI (sheet/modal) for the selected tag.
2. Edit form fields:
- `name`
- `description`
- `category` (group selector)
- `color` (required fixed 10-color swatch picker in popover with live preview)
3. Save/cancel controls follow existing form action conventions.
### Required Create Tag Modal Fields
1. `name` (required)
2. `category` (required)
3. `slug` (required; auto-generated and editable)
4. `description` (optional)
5. `visibility` (required)
6. `color` (required fixed 10-color swatch picker in popover; preselect default color)
7. Save button disabled until required fields including color are valid.
### Required Color Selector Behavior
1. Use a popover selector (not free-form hex input and not full-spectrum picker).
2. Show exactly 10 predefined colors.
3. Selecting a swatch immediately updates preview dot and pending form state.
4. Persist selected value as the tag color token/hex.
5. Color palette (v1):
- `#EF4444`
- `#F97316`
- `#EAB308`
- `#22C55E`
- `#14B8A6`
- `#3B82F6`
- `#6366F1`
- `#A855F7`
- `#EC4899`
- `#64748B`
### Mobile Layout
1. Keep table semantics but allow stacked responsive rows.
2. Preserve group header + sub-item relationship.
3. Keep `Create` ButtonGroup + dropdown accessible at top.
4. Keep row-level `secondary` edit action visible without opening overflow menus.
## 2) Course Settings: Tag Assignment (`/courses/[id]/settings`)
### Placement
1. Add a `Tags` fieldset inside course settings, below course details section.
2. Keep existing unsaved-change pattern.
### Format
1. Group-by-group assignment UI:
- group heading.
- option controls based on mode.
2. `SINGLE_SELECT` group:
- radio-card options (one active).
3. `MULTI_SELECT` group:
- selectable chips with check state.
4. Footer summary:
- `n selected`.
- validation warnings (if any limits reached).
### Editing Behavior
1. Teacher/admin can only select from existing tags.
2. Archived tags are visible only when currently assigned (muted, non-selectable).
3. Save uses existing page `handleSave` flow.
## 3) Org Courses Catalog (`/org/[slug]/courses`)
### Desktop Format
1. Keep current top controls (`Search`, `Sort`, `View toggle`).
2. Add `Filters` trigger that opens left sidebar/panel with grouped facets.
3. Show active filters bar under controls.
4. Course grid/list remains unchanged structurally.
5. Hydrate filters from URL query parameters on first load:
- if `tags` is present, pre-select those tag filters.
- courses list must be filtered immediately using hydrated tag filters.
### Bulk Actions
1. Add checkbox selection mode for admins.
2. Selection action bar appears when one or more rows/cards selected.
3. Actions:
- add tags.
- remove tags.
4. Bulk action modal uses group-aware selector UI.
### Mobile Format
1. Facets open in bottom sheet/full-screen sheet.
2. Sticky footer in sheet:
- `Apply`.
- `Reset`.
## 4) LMS Explore (`/lms/explore`)
### Format
1. Keep existing search/sort bar.
2. Add compact facet strip:
- horizontal chips of group names.
- opens group selector panel.
3. Active filters bar above course cards.
### Mobile
1. One `Filter` button opens full-screen facet sheet.
2. Group accordions in sheet.
3. Apply/reset controls pinned to bottom.
## 5) LMS My Learning (`/lms/mylearning`)
### Format
1. Keep existing tabs (`In Progress`, `Complete`).
2. Add shared facet filter bar above tab content.
3. Selected filters persist across tab switches.
## 6) Public Org Courses (`/courses`)
### Page Structure
1. Header:
- page title (`All Courses`).
- subtitle with result count.
2. Body:
- desktop: left facet sidebar + right course grid.
- mobile: top filter button + sheet pattern.
### Facet Rules
1. Show only `is_public_filterable=true` groups.
2. Show only `PUBLIC` tags.
3. Preserve selected filters in URL query string.
### Pagination/Loading
1. Use infinite scroll or numbered pagination (pick one; numbered recommended for sharable pages).
2. Skeleton cards during load.
## States and Empty Cases
1. No groups configured:
- admin surfaces: prompt to create first group.
- student/public surfaces: hide facet UI, show courses normally.
2. No matching courses:
- show empty state with `Clear filters`.
3. Network error:
- inline alert + retry action.
4. Archived selection state:
- show muted chip with tooltip (`Archived tag`).
## Accessibility Formatting
1. All chip toggles keyboard focusable.
2. `SINGLE_SELECT` groups expose radio semantics.
3. `MULTI_SELECT` groups expose checkbox semantics.
4. Sheet/drawer focus trap and escape close.
5. `Clear all` and `Apply` must have accessible labels.
## Translation and Copy Structure
1. No hardcoded text in Svelte files.
2. Add key namespace:
- `tags.settings.*`
- `tags.filters.*`
- `tags.groups.*`
- `tags.public_courses.*`
3. Keep labels short:
- `Single Select`
- `Multi Select`
- `Public Filter`
- `Internal`
## Suggested New UI Components
1. `apps/dashboard/src/lib/features/tag/components/tag-chip.svelte`
2. `apps/dashboard/src/lib/features/tag/components/facet-group.svelte`
3. `apps/dashboard/src/lib/features/tag/components/facet-panel.svelte`
4. `apps/dashboard/src/lib/features/tag/components/active-filters-bar.svelte`
5. `apps/dashboard/src/lib/features/tag/components/tag-admin-grouped-table.svelte`
6. `apps/dashboard/src/lib/features/tag/components/tag-edit-sheet.svelte`
7. `apps/dashboard/src/lib/features/tag/components/tag-create-dropdown.svelte`
## UI Acceptance Checklist
1. Admin tags page uses `Page` shell + header `ButtonGroup + DropdownMenu` create actions.
2. Admin tags page uses grouped table format (group header rows + tag sub-item rows).
3. Each tag row has left color circle badge and `secondary` edit button.
4. `Total Courses` is the second column and is clickable.
5. Clicking `Total Courses` navigates to `/org/[slug]/courses?tags=<tagSlug>` and applies filtering automatically.
6. Edit flow updates tag name, description, and category.
7. Create/Edit tag modal includes required color picker and prevents save without color.
8. Color selector is a popover with exactly 10 swatches (no arbitrary color input).
9. Course settings supports grouped assignment with single-select and multi-select formatting.
10. Org courses supports filter panel + active filters bar + bulk tag actions.
11. LMS Explore/My Learning supports grouped filters with mobile sheet UX.
12. Public `/courses` supports faceted filtering with URL state.
13. All new copy is translation-key based.
+447
View File
@@ -0,0 +1,447 @@
# Webhooks PRD
## Purpose
Enable organization admins to send real-time ClassroomIO events to external systems (CRM, Slack, custom backend, analytics, HRIS) through secure outbound webhooks.
This closes the current automation gap between ClassroomIO actions and downstream business workflows.
## Problem Statement
Today, ClassroomIO has no outbound webhook product:
- Organizations cannot trigger external workflows when key events happen (invites, enrollments, submissions, payment requests, etc.).
- Teams rely on manual exports, inbox monitoring, or custom polling against internal APIs.
- Current integrations UI (`/settings/integrations`) is Telegram-specific and does not provide event-driven automation.
- The public API PRD already reserved an "Automation > Webhooks" tab, but it is currently "coming soon."
## Current-State Audit
| Capability | Current State | Notes |
| --- | --- | --- |
| Outbound webhooks | missing | No endpoint configuration, no delivery system |
| Event audit sources | partial | Strong invite audit trails exist (`course_invite_audit`, `organization_invite_audit`) |
| Trigger points in services | available | Existing service flows for invites, members, submissions, payments, newsfeed, community, lesson completion |
| Integrations UI | partial | Telegram-only settings page exists |
| API auth middleware | available | `authMiddleware`, `orgAdminMiddleware`, `apiKeyMiddleware` patterns already in use |
| Queue/worker infra | partial | Redis exists for rate-limiting; no webhook delivery queue yet |
| API documentation pipeline | available | OpenAPI generation/publishing flow already defined in repo |
## Data Sources Checked
- `apps/api/src/services/course/invite.ts`
- `apps/api/src/services/organization/invite.ts`
- `apps/api/src/services/course/people.ts`
- `apps/api/src/services/submission/submission.ts`
- `apps/api/src/services/course/payment-request.ts`
- `apps/api/src/services/newsfeed/newsfeed.ts`
- `apps/api/src/services/community.ts`
- `apps/api/src/services/lesson/lesson.ts`
- `apps/api/src/routes/invite/invite.ts`
- `apps/api/src/routes/course/invite.ts`
- `apps/api/src/routes/course/people.ts`
- `apps/api/src/routes/course/submission.ts`
- `apps/api/src/routes/course/payment-request.ts`
- `apps/dashboard/src/routes/org/[slug]/settings/integrations/+page.svelte`
- `apps/dashboard/src/lib/features/settings/pages/integrations.svelte`
- `prd/public-api/README.md`
## Product Goals
1. Admins can create and manage organization-scoped webhook endpoints.
2. Admins can subscribe webhooks to all events or selected events.
3. ClassroomIO delivers signed event payloads reliably with retry and delivery logs.
4. Admins can inspect delivery history and manually redeliver failed (or any past) events.
5. Webhooks are exposed in a dedicated Automation UX (not Telegram settings).
6. Initial event catalog maps directly to existing domain actions already implemented in services.
## Non-Goals (v1)
- Incoming webhooks (ClassroomIO as receiver).
- OAuth app ecosystem / public app marketplace.
- Zapier app implementation (kept separate; can reuse webhook infrastructure later).
- Custom per-webhook payload templates.
- Exactly-once delivery guarantees.
- Branch-level or object-field-level subscription filters.
## User Stories
1. As an org admin, I want to send `course.submission.created` to Slack so tutors are alerted instantly.
2. As an org admin, I want to sync `course.invite.accepted` and `course.member.added` to my CRM.
3. As an org admin, I want a secret-signed payload so I can verify requests came from ClassroomIO.
4. As an org admin, I want delivery logs and redelivery so failures are operable and debuggable.
## UX Requirements
### Navigation and IA
- Add an org nav item: `Automation` (`/org/[slug]/automation`) for org admins.
- Page tabs:
- `API`
- `Zapier` (coming soon)
- `Webhooks`
- Keep existing `/settings/integrations` (Telegram) unchanged in v1.
### Webhooks Tab
#### List View
- Table columns:
- Name
- Payload URL
- Events count (`All` or count of selected events)
- Last delivery status
- Last delivery timestamp
- Active toggle
- Actions:
- Edit
- Send test payload
- View deliveries
- Delete
#### Add/Edit Form
Fields (GitHub-like, adapted to ClassroomIO):
- `Payload URL` (required, HTTPS recommended)
- `Content type` (v1: `application/json` only)
- `Secret` (optional but strongly recommended)
- `SSL verification`
- `Enable SSL verification` (default)
- `Disable` (warning shown; intended for dev/self-host)
- `Event selection mode`
- `Send me everything`
- `Let me select individual events`
- If individual mode: checkbox list of supported events with one-line descriptions.
- `Active` toggle.
#### Delivery Log View
- Filter by status (`Success`, `Failed`, `Pending`, `Retrying`).
- Shows:
- Event type
- Attempt number
- HTTP status
- Request timestamp
- Response time
- Retry schedule / next retry
- Delivery details panel:
- Request headers (including delivery and signature headers)
- Payload JSON
- Response status/body (truncated with copy action)
- Error message if network/TLS failure
- `Redeliver` action for any delivery event retained in window.
## Event Catalog (v1)
Event names use dot notation: `domain.entity.action`.
| Event Type | Trigger Point | Source |
| --- | --- | --- |
| `organization.invite.created` | Team invite created | `services/organization/invite.ts` |
| `organization.invite.accepted` | Team invite accepted | `services/organization/invite.ts` |
| `organization.invite.revoked` | Team invite revoked/superseded | `services/organization/invite.ts` |
| `course.invite.created` | Student invite created | `services/course/invite.ts` |
| `course.invite.accepted` | Student invite accepted/enrolled | `services/course/invite.ts` |
| `course.invite.revoked` | Student invite revoked | `services/course/invite.ts` |
| `course.member.added` | Member added to course | `services/course/people.ts` |
| `course.submission.created` | Exercise submission created | `services/submission/submission.ts` |
| `course.submission.status_changed` | Submission status updated (e.g. graded) | `services/submission/submission.ts` |
| `course.payment_request.created` | Payment request form submitted | `services/course/payment-request.ts` |
| `course.newsfeed.post.created` | Newsfeed post created | `services/newsfeed/newsfeed.ts` |
| `course.newsfeed.comment.created` | Newsfeed comment created | `services/newsfeed/newsfeed.ts` |
| `community.question.created` | Community post created | `services/community.ts` |
| `community.answer.created` | Community comment/answer created | `services/community.ts` |
| `lesson.completion.updated` | Learner completion state changed | `services/lesson/lesson.ts` |
Default recommendation in UI for first-time setup:
- Pre-check high-signal workflow events:
- `course.invite.accepted`
- `course.member.added`
- `course.submission.created`
- `course.submission.status_changed`
- `course.payment_request.created`
## Payload Contract
### HTTP Headers
- `Content-Type: application/json`
- `User-Agent: ClassroomIO-Webhooks/1.0`
- `X-Cio-Event: <event_type>`
- `X-Cio-Delivery: <delivery_id>`
- `X-Cio-Webhook: <webhook_id>`
- `X-Cio-Timestamp: <unix_seconds>`
- `X-Cio-Signature-256: sha256=<hmac_hex>` (present when secret configured)
### Signature
- Algorithm: HMAC-SHA256
- Signed content: `<timestamp>.<raw_request_body>`
- Secret handling:
- Stored encrypted at rest (not plain text)
- Not returned from read APIs after creation
- Verification window recommendation to consumers: 5 minutes.
### Event Body Envelope
```json
{
"id": "evt_01JXXXX",
"type": "course.submission.created",
"createdAt": "2026-02-24T12:15:30.000Z",
"apiVersion": "2026-02-24",
"organization": {
"id": "org_uuid",
"siteName": "acme"
},
"data": {
"submissionId": "sub_uuid",
"courseId": "course_uuid",
"exerciseId": "exercise_uuid",
"submittedByProfileId": "profile_uuid",
"statusId": 1
}
}
```
### Delivery Semantics
- At-least-once delivery.
- Consumers must treat `event.id` as idempotency key.
- Success = any 2xx response.
- Non-2xx or network/TLS timeout triggers retry policy.
## Reliability and Retry Policy
- Request timeout per attempt: 10 seconds.
- Max attempts: 6.
- Backoff schedule: `10s`, `1m`, `5m`, `30m`, `2h`, `6h`.
- Per-endpoint in-flight limit: 1 concurrent delivery (preserve ordering per endpoint).
- Retention:
- Event payloads: 30 days.
- Delivery attempts: 30 days.
- Automatic endpoint health behavior:
- Mark endpoint `degraded` after 20 consecutive failed deliveries.
- Auto-disable endpoint after 100 consecutive failed deliveries (admin can re-enable).
## Security Requirements
1. Only org admins can create/update/delete webhooks.
2. Secret is write-only in API/UI (shown once at creation or regeneration).
3. URL validation:
- Reject localhost and private network targets by default in cloud mode.
- Allow private/internal targets only when `PUBLIC_IS_SELFHOSTED=true`.
4. TLS verification enabled by default.
5. Delivery logs redact sensitive response headers/body beyond configurable truncation limits.
## Technical Design
### Data Model
Add webhook tables in `packages/db/src/schema.ts`.
1. `webhook_endpoint`
- `id`
- `organization_id`
- `name`
- `payload_url`
- `content_type` (`application/json` in v1)
- `secret_ciphertext` (encrypted)
- `secret_last4`
- `verify_ssl`
- `is_active`
- `created_by_profile_id`
- `updated_by_profile_id`
- `created_at`, `updated_at`
2. `webhook_subscription`
- `id`
- `webhook_id`
- `event_type`
- unique (`webhook_id`, `event_type`)
3. `webhook_event`
- `id` (`evt_...` stable idempotency key)
- `organization_id`
- `event_type`
- `payload` (`jsonb`)
- `created_at`
4. `webhook_delivery`
- `id` (`whd_...`)
- `webhook_id`
- `event_id`
- `attempt`
- `status` (`PENDING | SUCCESS | FAILED | GAVE_UP`)
- `next_retry_at`
- `response_status`
- `response_body` (truncated)
- `error_message`
- `duration_ms`
- `delivered_at`
- `created_at`
### Validation Layer
New package:
- `packages/utils/src/validation/webhook/webhook.ts`
- `packages/utils/src/validation/webhook/index.ts`
- export from `packages/utils/src/validation/index.ts`
Schemas:
- `ZCreateWebhook`
- `ZUpdateWebhook`
- `ZWebhookParams`
- `ZWebhookDeliveryListQuery`
- `ZRedeliverWebhookDeliveryParams`
- `ZSendWebhookTest`
### Query Layer
New package:
- `packages/db/src/queries/webhook/webhook.ts`
- `packages/db/src/queries/webhook/index.ts`
Core query functions:
- endpoint CRUD
- subscription CRUD
- enqueue event
- enqueue deliveries for subscribed endpoints
- claim due deliveries (`FOR UPDATE SKIP LOCKED`)
- record success/failure and schedule retries
- list webhook deliveries for dashboard
### Service Layer
New services:
- `apps/api/src/services/webhook/webhook.ts` (management + listing)
- `apps/api/src/services/webhook/events.ts` (`emitWebhookEvent`)
- `apps/api/src/services/webhook/delivery.ts` (sign/send/retry worker)
Producer integration:
- call `emitWebhookEvent(...)` inside existing domain services after successful writes:
- course invite
- organization invite
- course people
- submission
- payment request
- newsfeed
- community
- lesson completion
### Route Layer
New routes under organization scope:
- `GET /organization/webhooks`
- `POST /organization/webhooks`
- `GET /organization/webhooks/:webhookId`
- `PUT /organization/webhooks/:webhookId`
- `DELETE /organization/webhooks/:webhookId`
- `POST /organization/webhooks/:webhookId/test`
- `GET /organization/webhooks/:webhookId/deliveries`
- `POST /organization/webhooks/:webhookId/deliveries/:deliveryId/redeliver`
Route guards:
- `authMiddleware`
- `orgAdminMiddleware` for mutating actions
- `orgMemberMiddleware` allowed for read-only delivery inspection if desired (decision: admin-only in v1 for simplicity)
### Delivery Worker
Worker runs in API process:
1. Poll due `webhook_delivery` rows at a fixed interval (e.g. every 5 seconds).
2. Lock rows, deliver, and update status atomically.
3. Schedule next retry by writing `next_retry_at`.
4. Expose internal manual trigger endpoint (API-key protected) for self-host/ops:
- `POST /internal/jobs/webhooks/process`
### Frontend
New feature module:
- `apps/dashboard/src/lib/features/automation/api/webhook.svelte.ts`
- `apps/dashboard/src/lib/features/automation/utils/types.ts`
- `apps/dashboard/src/lib/features/automation/pages/webhooks.svelte`
- `apps/dashboard/src/lib/features/automation/components/*`
- `apps/dashboard/src/routes/org/[slug]/automation/+page.svelte`
Translations:
- Add `org_navigation.automation`
- Add `automation.*` group:
- `automation.tabs.webhooks`
- `automation.webhooks.form.*`
- `automation.webhooks.events.*`
- `automation.webhooks.delivery.*`
- `automation.webhooks.snackbar.*`
## API and Docs Requirements
1. Add OpenAPI metadata for all webhook management routes.
2. Regenerate/publish OpenAPI spec using existing upload flow.
3. Add docs page in `apps/docs`:
- setup
- security/signature verification
- event catalog
- payload examples
- retry behavior and idempotency guidance
## Rollout Plan
### Phase 1: Backend Foundation
1. Schema + queries + validation.
2. Endpoint management routes.
3. Event enqueue + delivery worker + retry engine.
4. Basic API tests and signature tests.
### Phase 2: Dashboard UX
1. Automation route + Webhooks tab.
2. Create/edit form + event selector.
3. Delivery logs + redelivery + test delivery.
### Phase 3: Hardening and GA
1. Add observability dashboards and failure alerts.
2. Expand docs with language-specific signature verification snippets.
3. Enable additional low-priority events based on usage.
## Success Metrics
1. 10% of paid orgs create at least one webhook within 60 days of release.
2. >= 99% delivery success after retries for active endpoints.
3. p95 time-to-first-attempt under 30 seconds.
4. < 1% support tickets related to unverifiable signatures after docs launch.
## Risks and Mitigations
1. High-volume events (lesson completion) may increase queue pressure.
- Mitigation: keep event opt-in, enforce per-endpoint concurrency limits, track throughput metrics.
2. Misconfigured endpoints can create retry storms.
- Mitigation: exponential backoff, auto-disable thresholds, visible health state.
3. Secret handling mistakes can weaken security.
- Mitigation: encrypted storage, write-only UI semantics, strict signature contract docs.
## Build and Verification Commands (Implementation)
1. `pnpm --filter @cio/db build`
2. `pnpm --filter @cio/utils build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/dashboard build`
5. `pnpm --filter @cio/api upload:openapi`
## Open Questions
1. Should webhook management be Early Adopter+ only or Enterprise-only?
2. Should private-network payload URLs be allowed in cloud for approved enterprise tenants?
3. Should redelivery retention be 30 days or 90 days?
4. Should `lesson.completion.updated` ship in v1 or wait for post-GA volume tuning?