chore: ai, public course and other ui improvements
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# AI Course Assistant
|
||||
|
||||
## Status
|
||||
|
||||
- Done (shipped)
|
||||
|
||||
## Purpose
|
||||
|
||||
Deliver an AI assistant inside the course UI that teachers can use to:
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Compliance Course Type PRD
|
||||
|
||||
## Status
|
||||
- Draft
|
||||
- Done (shipped)
|
||||
|
||||
## Date
|
||||
- February 21, 2026 (updated April 11, 2026)
|
||||
@@ -1,5 +1,9 @@
|
||||
# Course Widget Embed PRD (Org Widgets)
|
||||
|
||||
## Status
|
||||
|
||||
- Done (shipped)
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -567,7 +571,7 @@ Parent (Dashboard) Iframe (Widget Runtime)
|
||||
```
|
||||
|
||||
#### Documentation Output
|
||||
- Spec file: `prd/course-widget-embed/iframe-protocol.md` (this section)
|
||||
- Spec file: `prd/course-widget-embed [DONE]/iframe-protocol.md` (this section)
|
||||
- Implementation: `apps/embeds/src/widgets/course-widget/preview/iframe-bridge.ts` + `apps/dashboard/src/lib/features/widget/preview/iframe-parent.ts`
|
||||
|
||||
### TODO-3: Preview Data Freshness (Implementation Details)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Cloudflare Custom Subdomain Reconnect PRD
|
||||
|
||||
## Status
|
||||
- Draft
|
||||
- Done (shipped)
|
||||
|
||||
## Date
|
||||
- March 22, 2026
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
- In progress
|
||||
- In progress (remaining question-type work; exercise question sections and video recording are tracked as separate shipped PRDs — see PRD-TRACKER DONE)
|
||||
|
||||
## Date
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Exercise Sections PRD
|
||||
# Exercise Question Sections PRD
|
||||
|
||||
## Status
|
||||
|
||||
- Draft
|
||||
- Done (shipped)
|
||||
|
||||
## Date
|
||||
|
||||
- February 26, 2026
|
||||
- April 30, 2026
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Allow instructors to group exercise questions into named sections, so exercises can be structured into logical parts (e.g., "Section A: Multiple Choice", "Section B: Written Responses") with per-section navigation, descriptions, and configurable post-section behavior.
|
||||
Allow instructors to group questions inside a single exercise into named sections, so one assignment, quiz, or exam can be structured into logical parts (e.g., "Section A: Multiple Choice", "Section B: Written Responses") with per-section instructions, navigation, and review.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Exercise question section**: A grouping inside one exercise. This PRD is about this concept.
|
||||
- **Course section**: The existing course content grouping that contains lessons and exercises. This PRD does not change course sections.
|
||||
- The existing `exercise.sectionId` field points to a **course section**. New question-level grouping must use `question.exerciseSectionId` / DB `question.exercise_section_id` so the two concepts are not overloaded.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
@@ -29,8 +35,8 @@ Today, every exercise is a flat list of questions. Instructors cannot:
|
||||
|
||||
| Area | Current State | Gap |
|
||||
| --- | --- | --- |
|
||||
| `exercise` table | Has `title`, `description`, questions linked via `question.exercise_id` | No section concept |
|
||||
| `question` table | Has `exercise_id`, `order`, `title`, `points`, `question_type_id` | No `section_id` or section ordering |
|
||||
| `exercise` table | Has `title`, `description`, and `section_id` for the parent course section; questions link via `question.exercise_id` | No internal question-section concept |
|
||||
| `question` table | Has `exercise_id`, `order`, `title`, `points`, `question_type_id` | No `exercise_section_id` or section ordering |
|
||||
| Exercise store | `questionnaire.questions` is a flat `Question[]` | No section grouping or section metadata |
|
||||
| Edit mode UI | Renders all questions in a single vertical list | No section headers, section CRUD, or drag-between-sections |
|
||||
| Take mode (view-mode) | Shows one question at a time via `currentQuestionIndex` on flat array | No section-at-a-time mode, no section overview screen |
|
||||
@@ -55,7 +61,7 @@ Today, every exercise is a flat list of questions. Instructors cannot:
|
||||
- `packages/ui/src/custom/exercise-question/question-navigation.svelte`
|
||||
- `packages/ui/src/custom/exercise-question/question-list.svelte`
|
||||
- `packages/question-types/src/exercise-types.ts`
|
||||
- `prd/exercise-question-types/README.md`
|
||||
- `prd/exercise-question-types [DONE]/README.md`
|
||||
|
||||
## Primary Users
|
||||
|
||||
@@ -69,8 +75,9 @@ Today, every exercise is a flat list of questions. Instructors cannot:
|
||||
2. Allow students to navigate exercises section-by-section with configurable question display (one-at-a-time or all-at-once within a section).
|
||||
3. Show section-level subtotals alongside overall totals in grading.
|
||||
4. Support configurable post-section behavior (continue, go to specific section, submit).
|
||||
5. Maintain full backward compatibility — existing sectionless exercises work unchanged.
|
||||
6. Support sections in exercise templates and cloning.
|
||||
5. Let instructors assign lightweight visual color themes to sections for easier scanning while authoring.
|
||||
6. Maintain full backward compatibility — existing sectionless exercises work unchanged.
|
||||
7. Support sections in exercise templates and cloning.
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
@@ -79,11 +86,12 @@ Today, every exercise is a flat list of questions. Instructors cannot:
|
||||
- Conditional/branching logic beyond post-section routing (no "if student scored < 50% on Section A, skip to Section C").
|
||||
- Section-level randomization separate from exercise-level randomization.
|
||||
- Nested sections (sections within sections).
|
||||
- Custom arbitrary color pickers. V1 uses a fixed set of color theme swatches.
|
||||
|
||||
## Confirmed Decisions
|
||||
## Proposed Decisions / MVP Assumptions
|
||||
|
||||
1. An exercise is either **flat questions** (no sections) or **sections with questions** — not a mix of both. If an exercise has sections, every question must belong to a section.
|
||||
2. A question belongs to exactly **one** section. No multi-section membership.
|
||||
2. A question belongs to exactly **one exercise question section**. No multi-section membership.
|
||||
3. Section scoring is the **sum of question points** within that section — no separate section-level weight multiplier.
|
||||
4. Student display mode is **configurable per exercise**: either "one question at a time" (current behavior, but section-aware) or "one section at a time" (all questions in the current section shown together).
|
||||
5. Students see a **section overview screen** before beginning each section (section title + description + question count + total points).
|
||||
@@ -94,6 +102,7 @@ Today, every exercise is a flat list of questions. Instructors cannot:
|
||||
10. Existing exercises without sections continue to work exactly as they do today. Sections are **fully optional**.
|
||||
11. Exercise templates support sections. Exercise cloning preserves sections.
|
||||
12. Instructors can **drag questions between sections**, **reorder sections**, and **select which section** when adding a new question.
|
||||
13. Section color themes are cosmetic only. They help authoring and review scanability and do not affect grading, routing, or student eligibility.
|
||||
|
||||
---
|
||||
|
||||
@@ -112,7 +121,8 @@ Today, every exercise is a flat list of questions. Instructors cannot:
|
||||
|
||||
- Instructor can edit the section **title** (required, min 1 character).
|
||||
- Instructor can edit the section **description** (optional, supports plain text).
|
||||
- Instructor can configure the **after-section behavior** via a dropdown at the bottom of each section:
|
||||
- Instructor can choose a section **color theme** from a fixed set of swatches. The selected theme appears as a dot in the section header and is persisted with the section.
|
||||
- Instructor can configure the **after-section behavior** via a compact settings row near the section card when routing is in scope:
|
||||
- "Continue to next section" (default)
|
||||
- "Go to section [X]" (dropdown listing all other sections by title)
|
||||
- "Submit exercise"
|
||||
@@ -219,6 +229,7 @@ If the student finishes the last section and its after-behavior is "Continue to
|
||||
"title": "Section Title",
|
||||
"description": "Optional description",
|
||||
"order": 0,
|
||||
"colorTheme": "blue",
|
||||
"afterBehavior": { "action": "continue" },
|
||||
"questions": [{ "title": "...", ... }]
|
||||
}
|
||||
@@ -232,7 +243,7 @@ If the student finishes the last section and its after-behavior is "Continue to
|
||||
### FR-6: Cloning
|
||||
|
||||
- The clone service (`apps/api/src/services/course/clone.ts`) clones exercise sections alongside questions and options.
|
||||
- Section ordering, titles, descriptions, and after-behavior settings are preserved.
|
||||
- Section ordering, titles, descriptions, color themes, and after-behavior settings are preserved.
|
||||
- Question-to-section mappings are preserved via the section ID map.
|
||||
|
||||
---
|
||||
@@ -250,6 +261,7 @@ CREATE TABLE exercise_section (
|
||||
title VARCHAR NOT NULL DEFAULT 'Untitled Section',
|
||||
description TEXT,
|
||||
"order" BIGINT NOT NULL DEFAULT 0,
|
||||
color_theme VARCHAR NOT NULL DEFAULT 'blue',
|
||||
after_behavior JSONB NOT NULL DEFAULT '{"action": "continue"}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
@@ -265,21 +277,21 @@ The `after_behavior` JSONB stores one of:
|
||||
{ action: 'continue' }
|
||||
|
||||
// Jump to a specific section
|
||||
{ action: 'go_to_section', sectionId: '<uuid>' }
|
||||
{ action: 'go_to_section', exerciseSectionId: '<uuid>' }
|
||||
|
||||
// Submit the exercise immediately
|
||||
{ action: 'submit' }
|
||||
```
|
||||
|
||||
#### 1.2: `question` Table — Add `section_id`
|
||||
#### 1.2: `question` Table — Add `exercise_section_id`
|
||||
|
||||
```sql
|
||||
ALTER TABLE question ADD COLUMN section_id UUID REFERENCES exercise_section(id) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_question_section_id ON question(section_id);
|
||||
ALTER TABLE question ADD COLUMN exercise_section_id UUID REFERENCES exercise_section(id) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_question_exercise_section_id ON question(exercise_section_id);
|
||||
```
|
||||
|
||||
- `section_id` is **nullable**. When `NULL`, the question belongs to a sectionless exercise (backward compatible).
|
||||
- When a question is part of a sectioned exercise, `section_id` is required (enforced at the application level, not DB constraint, to keep migration safe).
|
||||
- `exercise_section_id` is **nullable**. When `NULL`, the question belongs to a sectionless exercise (backward compatible).
|
||||
- When a question is part of a sectioned exercise, `exercise_section_id` is required (enforced at the application level, not DB constraint, to keep migration safe).
|
||||
|
||||
#### 1.3: `exercise` Table — Add `section_display_mode`
|
||||
|
||||
@@ -307,6 +319,7 @@ export const exerciseSection = pgTable(
|
||||
title: varchar().notNull().default('Untitled Section'),
|
||||
description: text(),
|
||||
order: bigint({ mode: 'number' }).notNull().default(0),
|
||||
colorTheme: varchar('color_theme').notNull().default('blue'),
|
||||
afterBehavior: jsonb('after_behavior')
|
||||
.notNull()
|
||||
.default({ action: 'continue' })
|
||||
@@ -328,16 +341,16 @@ export const exerciseSection = pgTable(
|
||||
Add to `question` table:
|
||||
|
||||
```typescript
|
||||
sectionId: uuid('section_id'),
|
||||
exerciseSectionId: uuid('exercise_section_id'),
|
||||
```
|
||||
|
||||
Add foreign key in `question` table constraints:
|
||||
|
||||
```typescript
|
||||
foreignKey({
|
||||
columns: [table.sectionId],
|
||||
columns: [table.exerciseSectionId],
|
||||
foreignColumns: [exerciseSection.id],
|
||||
name: 'question_section_id_fkey'
|
||||
name: 'question_exercise_section_id_fkey'
|
||||
}).onDelete('set null')
|
||||
```
|
||||
|
||||
@@ -363,16 +376,18 @@ export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit'
|
||||
|
||||
export interface ExerciseSectionAfterBehavior {
|
||||
action: ExerciseSectionAfterAction;
|
||||
sectionId?: string; // only when action === 'go_to_section'
|
||||
exerciseSectionId?: string; // only when action === 'go_to_section'
|
||||
}
|
||||
|
||||
export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions';
|
||||
export type ExerciseSectionColorTheme = 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
|
||||
|
||||
export interface ExerciseSectionModel {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
afterBehavior: ExerciseSectionAfterBehavior;
|
||||
questions: ExerciseQuestionModel[];
|
||||
}
|
||||
@@ -387,7 +402,7 @@ import { z } from 'zod';
|
||||
|
||||
export const ZExerciseSectionAfterBehavior = z.discriminatedUnion('action', [
|
||||
z.object({ action: z.literal('continue') }),
|
||||
z.object({ action: z.literal('go_to_section'), sectionId: z.string().uuid() }),
|
||||
z.object({ action: z.literal('go_to_section'), exerciseSectionId: z.string().uuid() }),
|
||||
z.object({ action: z.literal('submit') })
|
||||
]);
|
||||
|
||||
@@ -396,6 +411,7 @@ export const ZExerciseSection = z.object({
|
||||
title: z.string().min(1, 'Section title is required'),
|
||||
description: z.string().optional().nullable(),
|
||||
order: z.number().int().min(0),
|
||||
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).default('blue'),
|
||||
afterBehavior: ZExerciseSectionAfterBehavior.default({ action: 'continue' })
|
||||
});
|
||||
|
||||
@@ -430,11 +446,11 @@ Required query functions:
|
||||
| --- | --- |
|
||||
| `getExerciseSectionsByExerciseId(exerciseId)` | Fetch all sections for an exercise, ordered by `order` |
|
||||
| `createExerciseSections(sections[])` | Bulk insert sections, return created rows |
|
||||
| `updateExerciseSection(sectionId, data)` | Update a single section's title, description, order, or afterBehavior |
|
||||
| `deleteExerciseSection(sectionId)` | Delete a section (questions cascade to `section_id = NULL`) |
|
||||
| `updateExerciseSection(exerciseSectionId, data)` | Update a single section's title, description, colorTheme, order, or afterBehavior |
|
||||
| `deleteExerciseSection(exerciseSectionId)` | Delete a section (questions cascade to `exercise_section_id = NULL`) |
|
||||
| `reorderExerciseSections(exerciseId, orderedIds[])` | Batch update `order` field for all sections |
|
||||
| `moveQuestionToSection(questionId, sectionId)` | Update a question's `section_id` |
|
||||
| `bulkMoveQuestionsToSection(questionIds[], sectionId)` | Move multiple questions to a section |
|
||||
| `moveQuestionToSection(questionId, exerciseSectionId)` | Update a question's `exercise_section_id` |
|
||||
| `bulkMoveQuestionsToSection(questionIds[], exerciseSectionId)` | Move multiple questions to a section |
|
||||
|
||||
### 4. API Layer Changes
|
||||
|
||||
@@ -453,7 +469,8 @@ When fetching an exercise (`GET /course/:courseId/exercise/:exerciseId`), the re
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
afterBehavior: { action: string; sectionId?: string };
|
||||
colorTheme: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
|
||||
afterBehavior: { action: string; exerciseSectionId?: string };
|
||||
questions: Array<{ /* existing question shape */ }>;
|
||||
}>;
|
||||
questions: Array<{ /* questions without sections (backward compat) */ }>;
|
||||
@@ -478,7 +495,8 @@ The `PUT /course/:courseId/exercise/:exerciseId` endpoint accepts an optional `s
|
||||
title: string;
|
||||
description?: string;
|
||||
order: number;
|
||||
afterBehavior: { action: string; sectionId?: string };
|
||||
colorTheme?: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
|
||||
afterBehavior: { action: string; exerciseSectionId?: string };
|
||||
questionIds: string[]; // ordered list of question IDs in this section
|
||||
}>;
|
||||
questions?: Array<{ /* existing question update shape */ }>;
|
||||
@@ -488,7 +506,7 @@ The `PUT /course/:courseId/exercise/:exerciseId` endpoint accepts an optional `s
|
||||
Service logic:
|
||||
|
||||
1. Upsert sections (create new, update existing, delete removed).
|
||||
2. Update `question.section_id` for each question based on `questionIds` in each section.
|
||||
2. Update `question.exercise_section_id` for each question based on `questionIds` in each section.
|
||||
3. Update `question.order` within each section.
|
||||
4. Validate that all `go_to_section` references point to valid section IDs within the same exercise.
|
||||
5. Validate that if sections exist, every non-deleted question has a section assignment.
|
||||
@@ -504,7 +522,7 @@ When fetching submissions for grading, the API returns section groupings:
|
||||
```typescript
|
||||
{
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
exerciseSectionId: string;
|
||||
sectionTitle: string;
|
||||
subtotal: number;
|
||||
maxPoints: number;
|
||||
@@ -526,7 +544,7 @@ Update `cloneExercises` to:
|
||||
1. After creating new exercises, fetch sections for all old exercises.
|
||||
2. Create a section ID map (old → new).
|
||||
3. Bulk insert new sections with the new exercise IDs.
|
||||
4. When cloning questions, include the mapped `sectionId`.
|
||||
4. When cloning questions, include the mapped `exerciseSectionId`.
|
||||
|
||||
```typescript
|
||||
// Pseudocode addition to cloneExercises():
|
||||
@@ -539,22 +557,23 @@ if (oldSections.length > 0) {
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
exerciseId: exerciseIdMap.get(section.exerciseId)!
|
||||
}))
|
||||
);
|
||||
|
||||
const sectionIdMap = new Map<string, string>();
|
||||
const exerciseSectionIdMap = new Map<string, string>();
|
||||
oldSections.forEach((oldSection, index) => {
|
||||
sectionIdMap.set(oldSection.id, newSections[index].id);
|
||||
exerciseSectionIdMap.set(oldSection.id, newSections[index].id);
|
||||
});
|
||||
|
||||
// When cloning questions, include mapped sectionId:
|
||||
// sectionId: question.sectionId ? sectionIdMap.get(question.sectionId) : null
|
||||
// When cloning questions, include mapped exerciseSectionId:
|
||||
// exerciseSectionId: question.exerciseSectionId ? exerciseSectionIdMap.get(question.exerciseSectionId) : null
|
||||
}
|
||||
```
|
||||
|
||||
Update `afterBehavior` references: if a section's `afterBehavior.action === 'go_to_section'`, remap the `sectionId` using the section ID map.
|
||||
Update `afterBehavior` references: if a section's `afterBehavior.action === 'go_to_section'`, remap the `exerciseSectionId` using the section ID map.
|
||||
|
||||
### 6. Exercise Template Changes
|
||||
|
||||
@@ -568,7 +587,8 @@ questionnaire: jsonb().default({}).$type<{
|
||||
title: string;
|
||||
description?: string;
|
||||
order: number;
|
||||
afterBehavior: { action: string; sectionId?: string };
|
||||
colorTheme?: string;
|
||||
afterBehavior: { action: string; exerciseSectionId?: string };
|
||||
questions: Array<{
|
||||
title: string;
|
||||
name: string;
|
||||
@@ -594,6 +614,7 @@ export interface ExerciseSectionState {
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
afterBehavior: ExerciseSectionAfterBehavior;
|
||||
isDirty?: boolean;
|
||||
deletedAt?: string;
|
||||
@@ -626,13 +647,13 @@ export const questionnaire: Writable<{
|
||||
|
||||
#### 7.2: Extend Question Type
|
||||
|
||||
Add `sectionId` to the `Question` type:
|
||||
Add `exerciseSectionId` to the `Question` type:
|
||||
|
||||
```typescript
|
||||
export type Question = Omit<ApiQuestion, 'options'> & {
|
||||
isDirty?: boolean;
|
||||
deletedAt?: string;
|
||||
sectionId?: string | null;
|
||||
exerciseSectionId?: string | null;
|
||||
options: QuestionOption[];
|
||||
};
|
||||
```
|
||||
@@ -667,6 +688,7 @@ export function handleAddSection() {
|
||||
title: 'Untitled Section',
|
||||
description: null,
|
||||
order: q.sections.length,
|
||||
colorTheme: 'blue',
|
||||
afterBehavior: { action: 'continue' },
|
||||
isDirty: true
|
||||
};
|
||||
@@ -674,21 +696,21 @@ export function handleAddSection() {
|
||||
});
|
||||
}
|
||||
|
||||
export function handleRemoveSection(sectionId: string, moveQuestionsToSectionId?: string) {
|
||||
export function handleRemoveSection(exerciseSectionId: string, moveQuestionsToExerciseSectionId?: string) {
|
||||
questionnaire.update((q) => {
|
||||
const section = q.sections.find((s) => s.id === sectionId);
|
||||
const section = q.sections.find((s) => s.id === exerciseSectionId);
|
||||
if (!section) return q;
|
||||
|
||||
let updatedQuestions = q.questions;
|
||||
if (moveQuestionsToSectionId) {
|
||||
if (moveQuestionsToExerciseSectionId) {
|
||||
updatedQuestions = q.questions.map((question) =>
|
||||
question.sectionId === sectionId
|
||||
? { ...question, sectionId: moveQuestionsToSectionId, isDirty: true }
|
||||
question.exerciseSectionId === exerciseSectionId
|
||||
? { ...question, exerciseSectionId: moveQuestionsToExerciseSectionId, isDirty: true }
|
||||
: question
|
||||
);
|
||||
} else {
|
||||
updatedQuestions = q.questions.map((question) =>
|
||||
question.sectionId === sectionId
|
||||
question.exerciseSectionId === exerciseSectionId
|
||||
? { ...question, deletedAt: new Date().toString() }
|
||||
: question
|
||||
);
|
||||
@@ -697,32 +719,32 @@ export function handleRemoveSection(sectionId: string, moveQuestionsToSectionId?
|
||||
return {
|
||||
...q,
|
||||
sections: q.sections.map((s) =>
|
||||
s.id === sectionId ? { ...s, deletedAt: new Date().toString() } : s
|
||||
s.id === exerciseSectionId ? { ...s, deletedAt: new Date().toString() } : s
|
||||
),
|
||||
questions: updatedQuestions
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function handleMoveQuestionToSection(questionId: string, sectionId: string) {
|
||||
export function handleMoveQuestionToSection(questionId: string, exerciseSectionId: string) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
questions: q.questions.map((question) =>
|
||||
question.id === questionId
|
||||
? { ...question, sectionId, isDirty: true }
|
||||
? { ...question, exerciseSectionId, isDirty: true }
|
||||
: question
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleUpdateSectionAfterBehavior(
|
||||
sectionId: string,
|
||||
exerciseSectionId: string,
|
||||
afterBehavior: ExerciseSectionAfterBehavior
|
||||
) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
sections: q.sections.map((section) =>
|
||||
section.id === sectionId
|
||||
section.id === exerciseSectionId
|
||||
? { ...section, afterBehavior, isDirty: true }
|
||||
: section
|
||||
)
|
||||
@@ -731,10 +753,10 @@ export function handleUpdateSectionAfterBehavior(
|
||||
|
||||
export function getQuestionsForSection(
|
||||
questions: Question[],
|
||||
sectionId: string
|
||||
exerciseSectionId: string
|
||||
): Question[] {
|
||||
return questions
|
||||
.filter((q) => q.sectionId === sectionId && !q.deletedAt)
|
||||
.filter((q) => q.exerciseSectionId === exerciseSectionId && !q.deletedAt)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
@@ -745,73 +767,90 @@ export function hasSections(sections: ExerciseSectionState[]): boolean {
|
||||
|
||||
### 8. Dashboard UI Changes
|
||||
|
||||
#### 8.1: Edit Mode — Section Headers
|
||||
#### 8.1: Edit Mode — Section Cards
|
||||
|
||||
File: `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`
|
||||
|
||||
When sections exist, render questions grouped under collapsible section headers:
|
||||
When sections exist, render questions inside section cards with three distinct zones:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Section 1 of 2 ✕ ⋮ │
|
||||
│ [Section Title Input ] │
|
||||
│ [Description (optional) ] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─ Question Card ──────────────────┐ │
|
||||
│ │ Q1: What is... [Radio ▼] │ │
|
||||
│ │ ... │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ ┌─ Question Card ──────────────────┐ │
|
||||
│ │ Q2: Explain... [Textarea ▼] │ │
|
||||
│ │ ... │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ After section 1: [Continue ▼] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Section 2 of 2 ✕ ⋮ │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────┘
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ ⋮⋮ ● [Section title input ] [3 questions] × │ Header
|
||||
│ ● ● ● ● ● │ Color swatches
|
||||
├────────────────────────────────────────────────────────┤
|
||||
│ ┌─ Question sub-card ──────────────────────────────┐ │
|
||||
│ │ Q1: What is... [Radio ▼] │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ ┌─ Question sub-card ──────────────────────────────┐ │ Content
|
||||
│ │ Q2: Explain... [Textarea ▼]│ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ [ + Add question ] │
|
||||
├────────────────────────────────────────────────────────┤
|
||||
│ 3 required questions [↑] [↓] │ Footer
|
||||
└────────────────────────────────────────────────────────┘
|
||||
|
||||
[ + Add Section ]
|
||||
```
|
||||
|
||||
- Each section has an inline title input and optional description textarea.
|
||||
- The "After section N" dropdown appears at the bottom of each section, styled like the Google Forms pattern (see reference image).
|
||||
- The three-dot menu (`⋮`) on each section header offers: "Move section up", "Move section down", "Delete section".
|
||||
- The "Add question" button inside a section adds a question to that specific section.
|
||||
- Questions can be dragged between sections.
|
||||
Header:
|
||||
- Sits on a secondary background to visually separate it from the white content area.
|
||||
- Contains, from left to right: drag handle, color theme dot, editable title input, color swatches, question count badge, and remove button.
|
||||
- The title input should visually blend into the header background until focused.
|
||||
- Color swatches update the section's `colorTheme`; the dot reflects the selected theme.
|
||||
|
||||
#### 8.2: New Shared UI Component — `SectionHeader`
|
||||
Content:
|
||||
- Sits on the primary white background with comfortable padding.
|
||||
- Renders question items stacked vertically.
|
||||
- Each question item is its own bordered sub-card.
|
||||
- A centered "Add question" button appears below the question list and appends a new question to that section.
|
||||
|
||||
File: `packages/ui/src/custom/exercise-question/section-header.svelte` (new)
|
||||
Footer:
|
||||
- Separated from content by a top border.
|
||||
- Shows a small muted required-question count on the left. Until optional questions ship, this count equals the active question count.
|
||||
- Shows small up/down arrow icon buttons on the right for moving the section within the exercise.
|
||||
|
||||
If post-section routing remains in v1, render the "After section N" control as a compact settings row above the footer or in section settings. Do not overload the footer, which is reserved for required count and section reorder controls.
|
||||
If section descriptions remain in v1, expose description editing in an expandable section settings area rather than in the compact header.
|
||||
Questions can be dragged between sections, with context-menu movement as a fallback.
|
||||
|
||||
#### 8.2: New Dashboard Component — `SectionEditor`
|
||||
|
||||
File: `apps/dashboard/src/lib/features/course/components/exercise/section-editor.svelte` (new)
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
exerciseSectionId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
sectionNumber: number;
|
||||
totalSections: number;
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
questionCount: number;
|
||||
totalPoints: number;
|
||||
mode: 'edit' | 'take' | 'preview' | 'view';
|
||||
requiredQuestionCount: number;
|
||||
onTitleChange: (title: string) => void;
|
||||
onColorThemeChange: (theme: ExerciseSectionColorTheme) => void;
|
||||
onAddQuestion: () => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
- In **edit** mode: editable title/description inputs.
|
||||
- In **take** mode: read-only title, description, question count, points.
|
||||
- In **preview/view** mode: read-only header.
|
||||
- Renders the three-zone edit-mode section card described above.
|
||||
- Reuses existing question editor markup in the content zone.
|
||||
- Uses existing UI primitives for buttons, inputs, badges, and icon buttons.
|
||||
- Keep take/preview/read-only section headers separate from this editor component so student-facing screens do not inherit authoring controls.
|
||||
|
||||
#### 8.3: New Shared UI Component — `SectionAfterBehavior`
|
||||
|
||||
File: `packages/ui/src/custom/exercise-question/section-after-behavior.svelte` (new)
|
||||
|
||||
A dropdown component that renders the "After section N" control:
|
||||
A dropdown component that renders the "After section N" routing control. It should be visually adjacent to the section editor, but not inside the section editor footer.
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
exerciseSectionId: string;
|
||||
allSections: Array<{ id: string; title: string }>;
|
||||
afterBehavior: ExerciseSectionAfterBehavior;
|
||||
onChange: (behavior: ExerciseSectionAfterBehavior) => void;
|
||||
@@ -910,6 +949,7 @@ When sections exist, group questions under section headers:
|
||||
description={section.description}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={section.colorTheme}
|
||||
questionCount={getQuestionsForSection(questions, section.id).length}
|
||||
totalPoints={getSectionPoints(questions, section.id)}
|
||||
mode="preview"
|
||||
@@ -964,7 +1004,11 @@ Add under `course.navItem.lessons.exercises.all_exercises`:
|
||||
"section.delete_remove": "Delete questions",
|
||||
"section.move_up": "Move section up",
|
||||
"section.move_down": "Move section down",
|
||||
"section.drag": "Drag section",
|
||||
"section.reorder": "Reorder Sections",
|
||||
"section.question_count": "{count} questions",
|
||||
"section.required_count": "{count} required questions",
|
||||
"section.color_theme": "Section color",
|
||||
"section.after_behavior_prefix": "After section",
|
||||
"section.after_continue": "Continue to next section",
|
||||
"section.after_go_to": "Go to section",
|
||||
@@ -989,8 +1033,8 @@ Add under `course.navItem.lessons.exercises.all_exercises`:
|
||||
| Scenario | Behavior |
|
||||
| --- | --- |
|
||||
| Existing exercise, no sections | `sections` array is empty. All code paths fall through to existing flat-question logic. No migration needed. |
|
||||
| Existing exercise, first section added | All questions auto-assigned to "Section 1". `sectionId` set on all questions. |
|
||||
| Sectioned exercise, last section deleted | All questions become sectionless. `sectionId` set to `NULL`. Exercise reverts to flat mode. |
|
||||
| Existing exercise, first section added | All questions auto-assigned to "Section 1". `exerciseSectionId` set on all questions. |
|
||||
| Sectioned exercise, last section deleted | All questions become sectionless. `exerciseSectionId` set to `NULL`. Exercise reverts to flat mode. |
|
||||
| Old API clients that don't send `sections` | `sections` field is optional in update payload. Omitting it means "don't change sections." |
|
||||
| Exercise templates without sections | `sections` field is absent in JSONB. Template import uses `questions` array (current behavior). |
|
||||
|
||||
@@ -1001,10 +1045,11 @@ Add under `course.navItem.lessons.exercises.all_exercises`:
|
||||
### Database Migration
|
||||
|
||||
1. Create `exercise_section` table.
|
||||
2. Add `section_id` column to `question` table (nullable, FK to `exercise_section`).
|
||||
2. Add `exercise_section_id` column to `question` table (nullable, FK to `exercise_section`).
|
||||
3. Add `section_display_mode` column to `exercise` table (default `'one_question'`).
|
||||
4. Add index on `question.section_id`.
|
||||
5. No data migration needed — existing exercises remain sectionless.
|
||||
4. Add `color_theme` to `exercise_section` with default `'blue'`.
|
||||
5. Add index on `question.exercise_section_id`.
|
||||
6. No data migration needed — existing exercises remain sectionless.
|
||||
|
||||
### Rollout Strategy
|
||||
|
||||
@@ -1026,7 +1071,7 @@ Add under `course.navItem.lessons.exercises.all_exercises`:
|
||||
| `packages/db/src/queries/exercise/exercise-section.ts` | CRUD queries for exercise sections |
|
||||
| `packages/utils/src/validation/exercise/exercise-section.ts` | Zod schemas for section validation |
|
||||
| `packages/question-types/src/exercise-section-types.ts` | TypeScript types for sections |
|
||||
| `packages/ui/src/custom/exercise-question/section-header.svelte` | Section header UI component |
|
||||
| `packages/ui/src/custom/exercise-question/section-header.svelte` | Read-only section header for take/preview/view modes |
|
||||
| `packages/ui/src/custom/exercise-question/section-after-behavior.svelte` | After-section behavior dropdown |
|
||||
| `packages/ui/src/custom/exercise-question/section-navigation-sidebar.svelte` | Section-grouped question nav |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/section-editor.svelte` | Section editing card (edit mode) |
|
||||
@@ -1037,7 +1082,7 @@ Add under `course.navItem.lessons.exercises.all_exercises`:
|
||||
|
||||
| File | Changes |
|
||||
| --- | --- |
|
||||
| `packages/db/src/schema.ts` | Add `exerciseSection` table, `sectionId` to `question`, `sectionDisplayMode` to `exercise` |
|
||||
| `packages/db/src/schema.ts` | Add `exerciseSection` table with `colorTheme`, `exerciseSectionId` to `question`, `sectionDisplayMode` to `exercise` |
|
||||
| `packages/db/src/queries/exercise/index.ts` | Export new section queries |
|
||||
| `packages/question-types/src/index.ts` | Export `exercise-section-types` |
|
||||
| `packages/ui/src/custom/exercise-question/index.ts` | Export new section components |
|
||||
@@ -1046,14 +1091,14 @@ Add under `course.navItem.lessons.exercises.all_exercises`:
|
||||
| `apps/api/src/routes/course/exercise.ts` | Accept/return sections in exercise endpoints |
|
||||
| `apps/api/src/services/exercise/exercise.ts` | Section upsert logic in exercise update |
|
||||
| `apps/api/src/services/course/clone.ts` | Clone sections, remap IDs and afterBehavior references |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/store.ts` | Add `sections`, `sectionDisplayMode`, section helpers |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/store.ts` | Add `sections`, `sectionDisplayMode`, `colorTheme`, section helpers |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte` | Render section-grouped editor |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte` | Section-aware take flow |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/preview.svelte` | Section-grouped preview |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/submissions/individual.svelte` | Section-grouped grading |
|
||||
| `apps/dashboard/src/lib/features/course/components/exercise/submissions/summary.svelte` | Section-grouped analytics |
|
||||
| `apps/dashboard/src/lib/features/course/pages/exercise.svelte` | "Add Section" button, section display mode setting |
|
||||
| `apps/dashboard/src/lib/features/course/types.ts` | Extend `Question` with `sectionId` |
|
||||
| `apps/dashboard/src/lib/features/course/types.ts` | Extend `Question` with `exerciseSectionId` |
|
||||
| `apps/dashboard/src/lib/utils/translations/en.json` | Section-related translation keys |
|
||||
|
||||
---
|
||||
@@ -1066,7 +1111,7 @@ Add under `course.navItem.lessons.exercises.all_exercises`:
|
||||
- Exercise update with sections — questions correctly assigned to sections.
|
||||
- Exercise fetch returns sections with nested questions in correct order.
|
||||
- After-behavior validation — `go_to_section` must reference a valid section.
|
||||
- Clone service preserves sections, question mappings, and remaps afterBehavior sectionIds.
|
||||
- Clone service preserves sections, question mappings, and remaps afterBehavior exerciseSectionIds.
|
||||
- Backward compat: exercise without sections works identically to before.
|
||||
- Submission fetch returns section-grouped data when sections exist.
|
||||
|
||||
@@ -1124,12 +1169,13 @@ pnpm --filter @cio/dashboard build
|
||||
| After-behavior "go to section" can create loops (A→B→A) | Validate at save time: warn if cycles detected, but allow (instructor may want loops intentionally for review) |
|
||||
| Drag-and-drop between sections may be unreliable on mobile | Provide "Move to section" context menu as a fallback; drag-drop is enhancement only |
|
||||
| Large exercises with many sections may slow down the editor | Lazy-render collapsed sections; only expand the active section |
|
||||
| Clone service must remap `afterBehavior.sectionId` references | Explicit remap step in clone logic with validation |
|
||||
| Clone service must remap `afterBehavior.exerciseSectionId` references | Explicit remap step in clone logic with validation |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should we support a "section shuffle" mode (randomize section order) independently of question shuffle?
|
||||
2. Should the section navigation sidebar be collapsible or always visible during take mode?
|
||||
3. Should after-behavior "Go to section X" carry over answered data, or should the target section reset?
|
||||
1. Should v1 include Google Forms-style post-section routing, or should v1 only support linear sections with submit at the end?
|
||||
2. Should we support a "section shuffle" mode (randomize section order) independently of question shuffle?
|
||||
3. Should the section navigation sidebar be collapsible or always visible during take mode?
|
||||
4. Should after-behavior "Go to section X" carry over answered data, or should the target section reset?
|
||||
+144
-120
@@ -1,4 +1,6 @@
|
||||
# Exercise Sections — Implementation Plan
|
||||
# Exercise Question Sections — Implementation Plan
|
||||
|
||||
This plan implements sections inside a single exercise. It does not change course sections. The existing `exercise.sectionId` field continues to mean the course section that contains an exercise; the new question grouping uses `question.exerciseSectionId` / DB `question.exercise_section_id`.
|
||||
|
||||
## Delivery Phases
|
||||
|
||||
@@ -32,6 +34,7 @@ export const exerciseSection = pgTable(
|
||||
title: varchar().notNull().default('Untitled Section'),
|
||||
description: text(),
|
||||
order: bigint({ mode: 'number' }).notNull().default(0),
|
||||
colorTheme: varchar('color_theme').notNull().default('blue'),
|
||||
afterBehavior: jsonb('after_behavior')
|
||||
.notNull()
|
||||
.default({ action: 'continue' }),
|
||||
@@ -49,19 +52,19 @@ export const exerciseSection = pgTable(
|
||||
);
|
||||
```
|
||||
|
||||
3. Add `sectionId` column to the existing `question` table definition. Find the `question` table object and add this column after `exerciseId`:
|
||||
3. Add `exerciseSectionId` column to the existing `question` table definition. Find the `question` table object and add this column after `exerciseId`:
|
||||
|
||||
```typescript
|
||||
sectionId: uuid('section_id'),
|
||||
exerciseSectionId: uuid('exercise_section_id'),
|
||||
```
|
||||
|
||||
4. Add a foreign key constraint in the `question` table's constraint array:
|
||||
|
||||
```typescript
|
||||
foreignKey({
|
||||
columns: [table.sectionId],
|
||||
columns: [table.exerciseSectionId],
|
||||
foreignColumns: [exerciseSection.id],
|
||||
name: 'question_section_id_fkey'
|
||||
name: 'question_exercise_section_id_fkey'
|
||||
}).onDelete('set null')
|
||||
```
|
||||
|
||||
@@ -89,18 +92,19 @@ sectionDisplayMode: varchar('section_display_mode').default('one_question'),
|
||||
**What to do**:
|
||||
|
||||
1. Generate a Drizzle migration from the schema changes in Ticket 1.1.
|
||||
2. The generated SQL should create the `exercise_section` table, add `section_id` to `question`, add `section_display_mode` to `exercise`, and create the index.
|
||||
2. The generated SQL should create the `exercise_section` table, add `exercise_section_id` to `question`, add `section_display_mode` to `exercise`, and create the index.
|
||||
3. Review the generated migration carefully to ensure:
|
||||
- `section_id` on `question` is nullable (no `NOT NULL`).
|
||||
- `exercise_section_id` on `question` is nullable (no `NOT NULL`).
|
||||
- `section_display_mode` on `exercise` has default `'one_question'`.
|
||||
- `color_theme` on `exercise_section` has default `'blue'`.
|
||||
- The `exercise_section` table has `ON DELETE CASCADE` for `exercise_id`.
|
||||
- The `question.section_id` FK has `ON DELETE SET NULL`.
|
||||
- The `question.exercise_section_id` FK has `ON DELETE SET NULL`.
|
||||
|
||||
**How to verify**:
|
||||
- Migration runs successfully against a local database.
|
||||
- Existing data is not affected.
|
||||
- `SELECT * FROM exercise_section` returns empty.
|
||||
- `SELECT section_id FROM question LIMIT 1` returns `NULL`.
|
||||
- `SELECT exercise_section_id FROM question LIMIT 1` returns `NULL`.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,16 +124,18 @@ export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit'
|
||||
|
||||
export interface ExerciseSectionAfterBehavior {
|
||||
action: ExerciseSectionAfterAction;
|
||||
sectionId?: string;
|
||||
exerciseSectionId?: string;
|
||||
}
|
||||
|
||||
export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions';
|
||||
export type ExerciseSectionColorTheme = 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
|
||||
|
||||
export interface ExerciseSectionModel {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
afterBehavior: ExerciseSectionAfterBehavior;
|
||||
questions: ExerciseQuestionModel[];
|
||||
}
|
||||
@@ -169,7 +175,7 @@ import { z } from 'zod';
|
||||
|
||||
export const ZExerciseSectionAfterBehavior = z.discriminatedUnion('action', [
|
||||
z.object({ action: z.literal('continue') }),
|
||||
z.object({ action: z.literal('go_to_section'), sectionId: z.string().uuid() }),
|
||||
z.object({ action: z.literal('go_to_section'), exerciseSectionId: z.string().uuid() }),
|
||||
z.object({ action: z.literal('submit') })
|
||||
]);
|
||||
|
||||
@@ -178,6 +184,7 @@ export const ZExerciseSection = z.object({
|
||||
title: z.string().min(1, 'Section title is required'),
|
||||
description: z.string().optional().nullable(),
|
||||
order: z.number().int().min(0),
|
||||
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).default('blue'),
|
||||
afterBehavior: ZExerciseSectionAfterBehavior.default({ action: 'continue' }),
|
||||
questionIds: z.array(z.string()).optional()
|
||||
});
|
||||
@@ -255,7 +262,8 @@ export async function createExerciseSections(
|
||||
title: string;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
afterBehavior?: { action: string; sectionId?: string };
|
||||
colorTheme?: string;
|
||||
afterBehavior?: { action: string; exerciseSectionId?: string };
|
||||
}>
|
||||
) {
|
||||
if (sections.length === 0) return [];
|
||||
@@ -263,37 +271,38 @@ export async function createExerciseSections(
|
||||
}
|
||||
|
||||
export async function updateExerciseSection(
|
||||
sectionId: string,
|
||||
exerciseSectionId: string,
|
||||
data: Partial<{
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
afterBehavior: { action: string; sectionId?: string };
|
||||
colorTheme: string;
|
||||
afterBehavior: { action: string; exerciseSectionId?: string };
|
||||
}>
|
||||
) {
|
||||
return db
|
||||
.update(exerciseSection)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(exerciseSection.id, sectionId))
|
||||
.where(eq(exerciseSection.id, exerciseSectionId))
|
||||
.returning();
|
||||
}
|
||||
|
||||
export async function deleteExerciseSection(sectionId: string) {
|
||||
return db.delete(exerciseSection).where(eq(exerciseSection.id, sectionId));
|
||||
export async function deleteExerciseSection(exerciseSectionId: string) {
|
||||
return db.delete(exerciseSection).where(eq(exerciseSection.id, exerciseSectionId));
|
||||
}
|
||||
|
||||
export async function moveQuestionToSection(questionId: number, sectionId: string | null) {
|
||||
export async function moveQuestionToSection(questionId: number, exerciseSectionId: string | null) {
|
||||
return db
|
||||
.update(question)
|
||||
.set({ sectionId })
|
||||
.set({ exerciseSectionId })
|
||||
.where(eq(question.id, questionId));
|
||||
}
|
||||
|
||||
export async function bulkMoveQuestionsToSection(questionIds: number[], sectionId: string | null) {
|
||||
export async function bulkMoveQuestionsToSection(questionIds: number[], exerciseSectionId: string | null) {
|
||||
if (questionIds.length === 0) return;
|
||||
return db
|
||||
.update(question)
|
||||
.set({ sectionId })
|
||||
.set({ exerciseSectionId })
|
||||
.where(inArray(question.id, questionIds));
|
||||
}
|
||||
```
|
||||
@@ -335,9 +344,9 @@ export * from './exercise-section';
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
```
|
||||
|
||||
3. Group questions by `sectionId`:
|
||||
- Questions with a `sectionId` go into their respective section's `questions` array.
|
||||
- Questions without a `sectionId` go into the top-level `questions` array.
|
||||
3. Group questions by `exerciseSectionId`:
|
||||
- Questions with an `exerciseSectionId` go into their respective section's `questions` array.
|
||||
- Questions without an `exerciseSectionId` go into the top-level `questions` array.
|
||||
4. Return the response shaped as:
|
||||
|
||||
```typescript
|
||||
@@ -349,6 +358,7 @@ const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questions: questionsInSection // sorted by order
|
||||
})),
|
||||
@@ -381,19 +391,19 @@ const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
|
||||
```
|
||||
For each section in the payload:
|
||||
- If section has `id` and exists in DB → update title, description, order, afterBehavior
|
||||
- If section has `id` and exists in DB → update title, description, colorTheme, order, afterBehavior
|
||||
- If section has no `id` → create new section
|
||||
- Sections in DB that are NOT in the payload → delete them
|
||||
|
||||
For each section:
|
||||
- Get `questionIds` from the section payload
|
||||
- Update each question's `sectionId` to this section's ID
|
||||
- Update each question's `exerciseSectionId` to this section's ID
|
||||
- Update each question's `order` based on its position in `questionIds`
|
||||
```
|
||||
|
||||
4. Validation rules:
|
||||
- If `sections` is provided and non-empty, every non-deleted question must be assigned to a section.
|
||||
- If any section has `afterBehavior.action === 'go_to_section'`, the referenced `sectionId` must exist in the same exercise.
|
||||
- If any section has `afterBehavior.action === 'go_to_section'`, the referenced `exerciseSectionId` must exist in the same exercise.
|
||||
- If `sections` is empty or omitted, don't touch existing sections.
|
||||
|
||||
5. Update `sectionDisplayMode` on the exercise if provided.
|
||||
@@ -433,7 +443,7 @@ For each section:
|
||||
// existing fields...
|
||||
sections: [
|
||||
{
|
||||
sectionId: 'uuid',
|
||||
exerciseSectionId: 'uuid',
|
||||
sectionTitle: 'Part A',
|
||||
subtotal: 15,
|
||||
maxPoints: 20,
|
||||
@@ -471,7 +481,7 @@ For each section:
|
||||
const oldExerciseIds = oldExercises.map((ex) => ex.id);
|
||||
const oldSections = await getExerciseSectionsByExerciseIds(oldExerciseIds);
|
||||
|
||||
let sectionIdMap = new Map<string, string>();
|
||||
let exerciseSectionIdMap = new Map<string, string>();
|
||||
|
||||
if (oldSections.length > 0) {
|
||||
// 4b. Create new sections with mapped exercise IDs
|
||||
@@ -480,6 +490,7 @@ if (oldSections.length > 0) {
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
exerciseId: exerciseIdMap.get(section.exerciseId)!
|
||||
}))
|
||||
@@ -487,17 +498,17 @@ if (oldSections.length > 0) {
|
||||
|
||||
// 4c. Build section ID map
|
||||
oldSections.forEach((oldSection, index) => {
|
||||
sectionIdMap.set(oldSection.id, newSections[index].id);
|
||||
exerciseSectionIdMap.set(oldSection.id, newSections[index].id);
|
||||
});
|
||||
|
||||
// 4d. Remap afterBehavior.sectionId references
|
||||
// 4d. Remap afterBehavior.exerciseSectionId references
|
||||
for (const newSection of newSections) {
|
||||
const behavior = newSection.afterBehavior as any;
|
||||
if (behavior?.action === 'go_to_section' && behavior.sectionId) {
|
||||
const remappedId = sectionIdMap.get(behavior.sectionId);
|
||||
if (behavior?.action === 'go_to_section' && behavior.exerciseSectionId) {
|
||||
const remappedId = exerciseSectionIdMap.get(behavior.exerciseSectionId);
|
||||
if (remappedId) {
|
||||
await updateExerciseSection(newSection.id, {
|
||||
afterBehavior: { action: 'go_to_section', sectionId: remappedId }
|
||||
afterBehavior: { action: 'go_to_section', exerciseSectionId: remappedId }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -505,10 +516,10 @@ if (oldSections.length > 0) {
|
||||
}
|
||||
```
|
||||
|
||||
3. When inserting cloned questions, include `sectionId`:
|
||||
3. When inserting cloned questions, include `exerciseSectionId`:
|
||||
|
||||
```typescript
|
||||
sectionId: question.sectionId ? sectionIdMap.get(question.sectionId) ?? null : null
|
||||
exerciseSectionId: question.exerciseSectionId ? exerciseSectionIdMap.get(question.exerciseSectionId) ?? null : null
|
||||
```
|
||||
|
||||
4. Also copy `sectionDisplayMode` when creating the new exercise.
|
||||
@@ -519,8 +530,9 @@ sectionId: question.sectionId ? sectionIdMap.get(question.sectionId) ?? null : n
|
||||
**How to verify**:
|
||||
- Clone a course with sectioned exercises.
|
||||
- Verify cloned exercise has same number of sections with correct titles.
|
||||
- Verify cloned section color themes are preserved.
|
||||
- Verify questions are assigned to correct cloned sections.
|
||||
- Verify afterBehavior `sectionId` references point to the NEW section IDs (not old ones).
|
||||
- Verify afterBehavior `exerciseSectionId` references point to the NEW section IDs (not old ones).
|
||||
- Verify `sectionDisplayMode` is preserved.
|
||||
|
||||
---
|
||||
@@ -542,6 +554,7 @@ sectionId: question.sectionId ? sectionIdMap.get(question.sectionId) ?? null : n
|
||||
```typescript
|
||||
import type {
|
||||
ExerciseSectionAfterBehavior,
|
||||
ExerciseSectionColorTheme,
|
||||
ExerciseSectionDisplayMode
|
||||
} from '@cio/question-types';
|
||||
|
||||
@@ -550,6 +563,7 @@ export interface ExerciseSectionState {
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
afterBehavior: ExerciseSectionAfterBehavior;
|
||||
isDirty?: boolean;
|
||||
deletedAt?: string;
|
||||
@@ -560,16 +574,17 @@ Add `sections` and `sectionDisplayMode` to the questionnaire writable's type and
|
||||
|
||||
3. Add helper functions (see PRD Section 7.4):
|
||||
- `handleAddSection()`
|
||||
- `handleRemoveSection(sectionId, moveQuestionsToSectionId?)`
|
||||
- `handleMoveQuestionToSection(questionId, sectionId)`
|
||||
- `handleUpdateSectionAfterBehavior(sectionId, afterBehavior)`
|
||||
- `getQuestionsForSection(questions, sectionId)`
|
||||
- `handleRemoveSection(exerciseSectionId, moveQuestionsToExerciseSectionId?)`
|
||||
- `handleMoveQuestionToSection(questionId, exerciseSectionId)`
|
||||
- `handleUpdateSectionAfterBehavior(exerciseSectionId, afterBehavior)`
|
||||
- `handleUpdateSectionColorTheme(exerciseSectionId, colorTheme)`
|
||||
- `getQuestionsForSection(questions, exerciseSectionId)`
|
||||
- `hasSections(sections)`
|
||||
- `handleReorderSections(orderedSectionIds)`
|
||||
|
||||
4. Update `handleAddQuestion()` to accept an optional `sectionId` parameter. When sections exist, the new question must have a `sectionId`.
|
||||
4. Update `handleAddQuestion()` to accept an optional `exerciseSectionId` parameter. When sections exist, the new question must have an `exerciseSectionId`.
|
||||
|
||||
5. Extend `Question` type in `apps/dashboard/src/lib/features/course/types.ts` to include `sectionId`.
|
||||
5. Extend `Question` type in `apps/dashboard/src/lib/features/course/types.ts` to include `exerciseSectionId`.
|
||||
|
||||
6. Extend the metadata store to include `currentSectionIndex` and `sectionPhase`.
|
||||
|
||||
@@ -580,31 +595,32 @@ Add `sections` and `sectionDisplayMode` to the questionnaire writable's type and
|
||||
**How to verify**:
|
||||
- Import and call `handleAddSection()` — store updates with a new section.
|
||||
- `getQuestionsForSection()` returns filtered questions.
|
||||
- `handleMoveQuestionToSection()` updates the question's `sectionId`.
|
||||
- `handleMoveQuestionToSection()` updates the question's `exerciseSectionId`.
|
||||
- `pnpm --filter @cio/dashboard build` succeeds.
|
||||
|
||||
---
|
||||
|
||||
### Ticket 3.2: Section Header UI Component
|
||||
### Ticket 3.2: Section Editor UI Component
|
||||
|
||||
**Priority**: P0
|
||||
**Estimate**: 2–3 hours
|
||||
|
||||
**What to do**:
|
||||
|
||||
1. Create `packages/ui/src/custom/exercise-question/section-header.svelte`:
|
||||
1. Create `apps/dashboard/src/lib/features/course/components/exercise/section-editor.svelte`. This is the edit-mode section card that wraps existing question editor markup.
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
exerciseSectionId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
sectionNumber: number;
|
||||
totalSections: number;
|
||||
questionCount?: number;
|
||||
totalPoints?: number;
|
||||
mode: 'edit' | 'take' | 'preview' | 'view';
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
questionCount: number;
|
||||
requiredQuestionCount: number;
|
||||
onTitleChange?: (title: string) => void;
|
||||
onDescriptionChange?: (description: string) => void;
|
||||
onColorThemeChange?: (colorTheme: ExerciseSectionColorTheme) => void;
|
||||
onAddQuestion?: () => void;
|
||||
onDelete?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
@@ -614,35 +630,32 @@ interface Props {
|
||||
}
|
||||
```
|
||||
|
||||
2. In **edit** mode, render:
|
||||
- A badge showing "Section N of M" (styled similar to the Google Forms purple badge in the reference image).
|
||||
- An editable title input (inline, not a dialog).
|
||||
- An optional description textarea (placeholder: "Description (optional)").
|
||||
- A close button (✕) and a three-dot menu with "Move up", "Move down", "Delete section".
|
||||
2. Render three distinct zones:
|
||||
- Header: secondary background, drag handle on the far left, selected color dot, editable title input that blends into the background, small color swatches, question count badge, and remove button.
|
||||
- Content: primary white background with padding, question items stacked vertically, each in a bordered sub-card, plus a centered "Add question" button below the list.
|
||||
- Footer: top border, muted required-question count on the left, small up/down arrow icon buttons on the right.
|
||||
|
||||
3. In **take** mode, render:
|
||||
- Section title (read-only, prominent).
|
||||
- Section description (read-only).
|
||||
- Question count and total points.
|
||||
- No edit controls.
|
||||
3. The color swatches use the supported `ExerciseSectionColorTheme` values. The swatch click updates the section state and marks it dirty.
|
||||
|
||||
4. In **preview/view** mode, render:
|
||||
- Section title as a heading.
|
||||
- Section description below it.
|
||||
- No interactive controls.
|
||||
4. Compute `requiredQuestionCount` from active questions where `required !== false`. Until optional questions are implemented, this equals the active question count.
|
||||
|
||||
5. Export from `packages/ui/src/custom/exercise-question/index.ts`.
|
||||
5. Use icon buttons for drag/reorder/remove affordances. Use existing UI button/input primitives and `ui:` prefixes for UI theme color classes.
|
||||
|
||||
6. If section descriptions remain in v1, expose description editing in an expandable section settings area instead of placing it in the compact header.
|
||||
|
||||
**Files to create**:
|
||||
- `packages/ui/src/custom/exercise-question/section-header.svelte`
|
||||
- `apps/dashboard/src/lib/features/course/components/exercise/section-editor.svelte`
|
||||
|
||||
**Files to change**:
|
||||
- `packages/ui/src/custom/exercise-question/index.ts`
|
||||
- `apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte`
|
||||
|
||||
**How to verify**:
|
||||
- Component renders correctly in all modes.
|
||||
- Edit mode: typing in title/description fires callbacks.
|
||||
- `pnpm --filter @cio/ui build` succeeds.
|
||||
- Header, content, and footer zones render with the expected backgrounds and borders.
|
||||
- Typing in the title fires the title callback.
|
||||
- Selecting a color swatch updates the section color dot and store state.
|
||||
- Clicking the centered "Add question" button creates a question assigned to the section.
|
||||
- Up/down arrow buttons reorder sections and respect disabled states at the first/last section.
|
||||
- `pnpm --filter @cio/dashboard build` succeeds.
|
||||
|
||||
---
|
||||
|
||||
@@ -670,15 +683,15 @@ interface Props {
|
||||
}
|
||||
```
|
||||
|
||||
2. Render a row at the bottom of each section card:
|
||||
2. Render a compact routing settings row near each section card, outside the section editor footer:
|
||||
- Label: "After section {N}" (left side).
|
||||
- A `<Select>` dropdown (right side) with options:
|
||||
- "Continue to next section" → `{ action: 'continue' }`
|
||||
- "Go to section: [Section Title]" → one entry per OTHER section → `{ action: 'go_to_section', sectionId: '...' }`
|
||||
- "Go to section: [Section Title]" → one entry per OTHER section → `{ action: 'go_to_section', exerciseSectionId: '...' }`
|
||||
- "Submit exercise" → `{ action: 'submit' }`
|
||||
- The currently selected option is shown in the trigger.
|
||||
|
||||
3. Style to match the Google Forms reference (see reference image): the row sits inside the section card, slightly separated from the questions above.
|
||||
3. Style it as a secondary settings row. Do not place it in the section editor footer; that footer is reserved for required-question count and section reorder controls.
|
||||
|
||||
4. Export from `packages/ui/src/custom/exercise-question/index.ts`.
|
||||
|
||||
@@ -713,44 +726,42 @@ This is the largest ticket. It modifies `edit-mode.svelte` to render questions i
|
||||
|
||||
```svelte
|
||||
{#each activeSections as section, sectionIndex}
|
||||
<div class="section-card">
|
||||
<SectionHeader
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
mode="edit"
|
||||
onTitleChange={(title) => updateSection(section.id, { title })}
|
||||
onDescriptionChange={(desc) => updateSection(section.id, { description: desc })}
|
||||
onDelete={() => initDeleteSection(section.id)}
|
||||
onMoveUp={() => moveSectionUp(section.id)}
|
||||
onMoveDown={() => moveSectionDown(section.id)}
|
||||
canMoveUp={sectionIndex > 0}
|
||||
canMoveDown={sectionIndex < activeSections.length - 1}
|
||||
/>
|
||||
|
||||
<SectionEditor
|
||||
exerciseSectionId={section.id}
|
||||
title={section.title}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={section.colorTheme}
|
||||
questionCount={getQuestionsForSection($questionnaire.questions, section.id).length}
|
||||
requiredQuestionCount={getRequiredQuestionCount(section.id)}
|
||||
onTitleChange={(title) => updateSection(section.id, { title })}
|
||||
onColorThemeChange={(colorTheme) => handleUpdateSectionColorTheme(section.id, colorTheme)}
|
||||
onAddQuestion={() => handleAddQuestion(section.id)}
|
||||
onDelete={() => initDeleteSection(section.id)}
|
||||
onMoveUp={() => moveSectionUp(section.id)}
|
||||
onMoveDown={() => moveSectionDown(section.id)}
|
||||
canMoveUp={sectionIndex > 0}
|
||||
canMoveDown={sectionIndex < activeSections.length - 1}
|
||||
>
|
||||
{#each getQuestionsForSection($questionnaire.questions, section.id) as question}
|
||||
{#if !question.deletedAt}
|
||||
<!-- Reuse existing QuestionContainer + QuestionRenderer markup -->
|
||||
<!-- Reuse existing QuestionContainer + QuestionRenderer markup inside a bordered sub-card -->
|
||||
<QuestionContainer ...>
|
||||
...existing question editing UI...
|
||||
</QuestionContainer>
|
||||
{/if}
|
||||
{/each}
|
||||
</SectionEditor>
|
||||
|
||||
<div class="section-footer">
|
||||
<Button onclick={() => handleAddQuestion(section.id)}>
|
||||
+ Add Question
|
||||
</Button>
|
||||
<SectionAfterBehavior
|
||||
sectionIndex={sectionIndex}
|
||||
allSections={activeSections}
|
||||
afterBehavior={section.afterBehavior}
|
||||
onChange={(behavior) => handleUpdateSectionAfterBehavior(section.id, behavior)}
|
||||
labels={sectionAfterBehaviorLabels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if sectionRoutingEnabled}
|
||||
<SectionAfterBehavior
|
||||
sectionIndex={sectionIndex}
|
||||
allSections={activeSections}
|
||||
afterBehavior={section.afterBehavior}
|
||||
onChange={(behavior) => handleUpdateSectionAfterBehavior(section.id, behavior)}
|
||||
labels={sectionAfterBehaviorLabels}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
```
|
||||
|
||||
@@ -776,7 +787,7 @@ Create `apps/dashboard/src/lib/features/course/components/exercise/section-delet
|
||||
|
||||
6. When the first section is added to a sectionless exercise, auto-create "Section 1" and move all existing questions into it.
|
||||
|
||||
7. When the last section is deleted, move all remaining questions to sectionless (set `sectionId` to `null`).
|
||||
7. When the last section is deleted, move all remaining questions to sectionless (set `exerciseSectionId` to `null`).
|
||||
|
||||
**Files to create**:
|
||||
- `apps/dashboard/src/lib/features/course/components/exercise/section-delete-modal.svelte`
|
||||
@@ -788,7 +799,8 @@ Create `apps/dashboard/src/lib/features/course/components/exercise/section-delet
|
||||
- Adding a section to a sectionless exercise creates "Section 1" with all existing questions.
|
||||
- Adding a second section creates an empty "Section 2".
|
||||
- Questions render inside their respective section cards.
|
||||
- Section title/description edits are reflected in the store.
|
||||
- Section title and color theme edits are reflected in the store.
|
||||
- Section card header, content, and footer match the three-zone UI.
|
||||
- After-behavior dropdown works and persists selection.
|
||||
- Deleting a section with "move questions" moves them.
|
||||
- Deleting a section with "delete questions" marks them as deleted.
|
||||
@@ -814,6 +826,7 @@ const sectionsPayload = activeSections.map((section) => ({
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questionIds: getQuestionsForSection($questionnaire.questions, section.id)
|
||||
.map((q) => q.id)
|
||||
@@ -862,14 +875,15 @@ questionnaire.set({
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior
|
||||
})),
|
||||
sectionDisplayMode: response.sectionDisplayMode || 'one_question',
|
||||
questions: allQuestions.map((q) => ({ ...q, sectionId: q.sectionId ?? null }))
|
||||
questions: allQuestions.map((q) => ({ ...q, exerciseSectionId: q.exerciseSectionId ?? null }))
|
||||
});
|
||||
```
|
||||
|
||||
3. Ensure `sectionId` is set on each question from the API response.
|
||||
3. Ensure `exerciseSectionId` is set on each question from the API response.
|
||||
|
||||
**Files to change**:
|
||||
- Exercise page load/fetch logic
|
||||
@@ -877,7 +891,7 @@ questionnaire.set({
|
||||
|
||||
**How to verify**:
|
||||
- Navigate to an exercise with sections — store has `sections` populated.
|
||||
- Questions have correct `sectionId` values.
|
||||
- Questions have correct `exerciseSectionId` values.
|
||||
- Navigate to a sectionless exercise — store has `sections: []`.
|
||||
|
||||
---
|
||||
@@ -915,7 +929,8 @@ questionnaire.set({
|
||||
**What to do**:
|
||||
|
||||
1. Open `apps/dashboard/src/lib/features/course/components/exercise/preview.svelte`.
|
||||
2. When sections exist, render questions grouped under section headers:
|
||||
2. Create or reuse a read-only `SectionHeader` component for take/preview/view modes. This is separate from the edit-mode `SectionEditor`.
|
||||
3. When sections exist, render questions grouped under section headers:
|
||||
|
||||
```svelte
|
||||
{#if hasSections(activeSections)}
|
||||
@@ -925,6 +940,7 @@ questionnaire.set({
|
||||
description={section.description}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={section.colorTheme}
|
||||
questionCount={sectionQuestions.length}
|
||||
totalPoints={sectionPoints}
|
||||
mode="preview"
|
||||
@@ -938,10 +954,12 @@ questionnaire.set({
|
||||
{/if}
|
||||
```
|
||||
|
||||
3. Questions are numbered within their section: "1.", "2." etc., resetting per section.
|
||||
4. Questions are numbered within their section: "1.", "2." etc., resetting per section.
|
||||
|
||||
**Files to change**:
|
||||
- `apps/dashboard/src/lib/features/course/components/exercise/preview.svelte`
|
||||
- `packages/ui/src/custom/exercise-question/section-header.svelte` (if not already created by take-mode work)
|
||||
- `packages/ui/src/custom/exercise-question/index.ts` (if creating the shared header)
|
||||
|
||||
**How to verify**:
|
||||
- Preview of sectioned exercise shows section headers and grouped questions.
|
||||
@@ -1098,7 +1116,7 @@ if hasSectionedExercise:
|
||||
→ if no more sections, submit exercise
|
||||
|
||||
if action === 'go_to_section':
|
||||
→ find target section by sectionId
|
||||
→ find target section by exerciseSectionId
|
||||
→ set currentSectionIndex to target's index, set sectionPhase to 'overview'
|
||||
|
||||
if action === 'submit':
|
||||
@@ -1270,10 +1288,8 @@ Ticket 1.3 (Types)
|
||||
│ │ └── Ticket 3.5 (Save Flow)
|
||||
│ ├── Ticket 3.6 (Fetch → Store)
|
||||
│ └── Ticket 4.3 (View Mode)
|
||||
├── Ticket 3.2 (Section Header)
|
||||
│ ├── Ticket 3.4 (Edit Mode)
|
||||
│ ├── Ticket 3.8 (Preview)
|
||||
│ └── Ticket 4.2 (Section Overview)
|
||||
├── Ticket 3.2 (Section Editor)
|
||||
│ └── Ticket 3.4 (Edit Mode)
|
||||
└── Ticket 3.3 (After-Behavior)
|
||||
└── Ticket 3.4 (Edit Mode)
|
||||
|
||||
@@ -1284,7 +1300,7 @@ Ticket 4.2 (Section Overview) → Ticket 4.3 (View Mode)
|
||||
Ticket 4.4 (Grading Individual) — depends on Ticket 2.3
|
||||
Ticket 4.5 (Grading Summary) — depends on Ticket 2.3
|
||||
Ticket 3.7 (Display Mode Setting) — depends on Ticket 3.1
|
||||
Ticket 3.8 (Preview) — depends on Ticket 3.2, 3.1
|
||||
Ticket 3.8 (Preview) — depends on Ticket 3.1
|
||||
```
|
||||
|
||||
## Suggested Work Order
|
||||
@@ -1300,7 +1316,7 @@ For a single developer working sequentially:
|
||||
7. **Ticket 2.1** — API Fetch
|
||||
8. **Ticket 2.2** — API Update
|
||||
9. **Ticket 3.1** — Store
|
||||
10. **Ticket 3.2** — Section Header
|
||||
10. **Ticket 3.2** — Section Editor
|
||||
11. **Ticket 3.3** — After-Behavior
|
||||
12. **Ticket 3.6** — Fetch → Store
|
||||
13. **Ticket 3.4** — Edit Mode
|
||||
@@ -1325,16 +1341,24 @@ For a single developer working sequentially:
|
||||
|
||||
| File | Status | Description |
|
||||
| --- | --- | --- |
|
||||
| `exercise-section-types.ts` | **New** | Section model, after-behavior types, display mode type |
|
||||
| `exercise-section-types.ts` | **New** | Section model, color theme type, after-behavior types, display mode type |
|
||||
| `index.ts` | Modified | Add section types export |
|
||||
|
||||
### `packages/ui/src/custom/exercise-question/`
|
||||
|
||||
| File | Status | Description |
|
||||
| --- | --- | --- |
|
||||
| `section-header.svelte` | **New** | Section header for edit/take/preview/view modes |
|
||||
| `section-header.svelte` | **New** | Read-only section header for take/preview/view modes |
|
||||
| `section-after-behavior.svelte` | **New** | After-section behavior dropdown |
|
||||
| `section-navigation-sidebar.svelte` | **New** | Section-grouped question navigation |
|
||||
| `question-navigation.svelte` | Modified | Add section-aware props |
|
||||
| `question-list.svelte` | Modified | Support section-grouped rendering |
|
||||
| `index.ts` | Modified | Export new components |
|
||||
|
||||
### `apps/dashboard/src/lib/features/course/components/exercise/`
|
||||
|
||||
| File | Status | Description |
|
||||
| --- | --- | --- |
|
||||
| `section-editor.svelte` | **New** | Three-zone section editing card: header, content, footer |
|
||||
| `section-overview.svelte` | **New** | Section overview screen for student take mode |
|
||||
| `section-delete-modal.svelte` | **New** | Section deletion confirmation |
|
||||
@@ -4,37 +4,37 @@ overview: Build an MCP-compatible authoring surface that lets external AI client
|
||||
todos:
|
||||
- id: prd-review
|
||||
content: Finalize PRD scope, product policy, and success metrics with stakeholders
|
||||
status: pending
|
||||
status: completed
|
||||
- id: auth-model
|
||||
content: Define shared organization automation key system reused by MCP and Public API
|
||||
status: pending
|
||||
status: completed
|
||||
- id: import-schema
|
||||
content: Finalize normalized course payload schema shared by prompt and PDF-derived flows
|
||||
status: pending
|
||||
status: completed
|
||||
- id: import-validation
|
||||
content: Add validation module for structured draft create, update, and publish requests
|
||||
status: pending
|
||||
status: completed
|
||||
- id: import-data-model
|
||||
content: Add draft persistence tables and query layer
|
||||
status: pending
|
||||
status: completed
|
||||
- id: import-api
|
||||
content: Add organization-scoped draft and publish routes
|
||||
status: pending
|
||||
status: completed
|
||||
- id: mcp-package
|
||||
content: Build MCP package with stdio transport and ClassroomIO tool adapters
|
||||
status: pending
|
||||
status: completed
|
||||
- id: publish-flow
|
||||
content: Implement draft-to-course publish orchestration using existing course, section, lesson, and language services
|
||||
status: pending
|
||||
status: completed
|
||||
- id: idempotency
|
||||
content: Add idempotency keys and duplicate publish guards for MCP-triggered imports
|
||||
status: pending
|
||||
status: completed
|
||||
- id: observability
|
||||
content: Add structured logs, audit trail, and rate limits for MCP tools
|
||||
status: pending
|
||||
status: completed
|
||||
- id: docs
|
||||
content: Document MCP tool contracts, auth setup, payload schema, and agent responsibilities
|
||||
status: pending
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
@@ -42,7 +42,7 @@ isProject: false
|
||||
|
||||
## Status
|
||||
|
||||
- Draft
|
||||
- Done (shipped)
|
||||
|
||||
## Date
|
||||
|
||||
@@ -172,8 +172,8 @@ After initial creation, Claude updates the draft by:
|
||||
- [packages/utils/src/validation/course/course.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/course/course.ts)
|
||||
- [packages/utils/src/validation/lesson/lesson.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/lesson/lesson.ts)
|
||||
- [packages/utils/src/validation/lesson/language.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/lesson/language.ts)
|
||||
- [prd/ai-course-assistant/README.md](/Users/rotimibest/_pros/classroomio/prd/ai-course-assistant/README.md)
|
||||
- [prd/public-api/README.md](/Users/rotimibest/_pros/classroomio/prd/public-api/README.md)
|
||||
- [prd/ai-course-assistant [DONE]/README.md](/Users/rotimibest/_pros/classroomio/prd/ai-course-assistant%20[DONE]/README.md)
|
||||
- [prd/public-api [DONE]/README.md](/Users/rotimibest/_pros/classroomio/prd/public-api%20[DONE]/README.md)
|
||||
|
||||
## User Personas
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
| MCP-D4 | MCP | Add `publish_course_draft` tool | MCP server tool files | MCP-C2 | Claude can publish reviewed drafts |
|
||||
| MCP-E1 | Security | Enforce org-scoped auth and rate limits | middleware + routes | MCP-A4 | Unauthorized access returns 401/403 |
|
||||
| MCP-E2 | Observability | Add structured logging and audit trail | services + middleware | MCP-C1 | Draft and publish actions are traceable |
|
||||
| MCP-E3 | Docs | Document setup and consumption contract | `prd/mcp-course-authoring/` and docs follow-up | MCP-D4 | Operator docs exist |
|
||||
| MCP-E3 | Docs | Document setup and consumption contract | `prd/mcp-course-authoring [DONE]/` and docs follow-up | MCP-D4 | Operator docs exist |
|
||||
|
||||
## Suggested First Sprint Slice
|
||||
|
||||
+4
@@ -1,5 +1,9 @@
|
||||
# Org Landing Page Theme Picker + Unified Content Editor
|
||||
|
||||
## Status
|
||||
|
||||
- Done (shipped)
|
||||
|
||||
## Context
|
||||
The org has 3 landing page theme templates (Minimal, Bold, Classic) in `packages/ui/` that all accept `OrgLandingPageProps` uniformly. However:
|
||||
1. The templates are **only used in Storybook** — the actual public landing page (`landing-page.svelte`) renders its own custom legacy HTML
|
||||
@@ -1,5 +1,9 @@
|
||||
# Programs PRD
|
||||
|
||||
## Status
|
||||
|
||||
- Done (shipped)
|
||||
|
||||
## Customer Documentation Draft
|
||||
|
||||
This section captures the customer-facing documentation draft for Programs. It is written for end users and can later be adapted into public docs, onboarding content, or help-center material.
|
||||
@@ -16,45 +16,49 @@ todos:
|
||||
status: completed
|
||||
- id: api-tab-page
|
||||
content: Replace the placeholder `/org/[slug]/automation/api` page with a real API page that follows the MCP page pattern
|
||||
status: pending
|
||||
status: completed
|
||||
- id: api-tab-server-load
|
||||
content: Add `/org/[slug]/automation/api/+page.server.ts` to load API key and usage data using the existing organization automation endpoints
|
||||
status: pending
|
||||
status: completed
|
||||
- id: api-tab-copy
|
||||
content: Add API-specific translations and content for docs links, usage messaging, and key management states
|
||||
status: pending
|
||||
status: completed
|
||||
- id: v1-validation-schemas
|
||||
content: Add public API input/output validation schemas on top of the existing automation-key schemas
|
||||
status: pending
|
||||
status: completed
|
||||
- id: v1-services
|
||||
content: Create versioned public API services for courses and audience/students
|
||||
status: pending
|
||||
status: completed
|
||||
- id: v1-routes
|
||||
content: Create and register `/v1` public API routes authenticated by organization automation keys
|
||||
status: pending
|
||||
status: completed
|
||||
- id: public-api-scopes
|
||||
content: Finalize the public API scope model and middleware requirements for `api` and `zapier` automation keys
|
||||
status: pending
|
||||
status: completed
|
||||
- id: tests
|
||||
content: Add API tests for automation auth, `/v1` routes, and contract coverage
|
||||
status: pending
|
||||
status: completed
|
||||
- id: openapi-contracts
|
||||
content: Ensure all public API routes are represented in generated OpenAPI output
|
||||
status: pending
|
||||
status: completed
|
||||
- id: openapi-publish
|
||||
content: Regenerate and upload the latest OpenAPI spec via `upload-openapi-spec.ts`
|
||||
status: pending
|
||||
status: completed
|
||||
- id: docs-openapi-integration
|
||||
content: Add the public API docs experience in `apps/docs`
|
||||
status: pending
|
||||
status: completed
|
||||
- id: build-verify
|
||||
content: Build and verify API and docs packages after implementation
|
||||
status: pending
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Public API with Organization-Scoped Automation Keys
|
||||
|
||||
## Status
|
||||
|
||||
- Done (shipped)
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a versioned public API on top of the existing organization automation platform. The current codebase already supports shared org-scoped automation keys and a dedicated Automation section in `/org`; this PRD now focuses on the remaining API product work rather than re-specifying the already-shipped foundation.
|
||||
+243
-430
@@ -1,488 +1,301 @@
|
||||
# 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)
|
||||
Enable course creators to publish **Public Courses** — a new course type where content is fully accessible without signup. Public courses treat every visitor as a guest: there is no "sign in to unlock" flow, no progress tracking, no submissions sent to the server. The goal is a low-friction, SEO-indexable surface that creators can use as top-of-funnel content marketing.
|
||||
|
||||
## 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
|
||||
1. Add a third course type: **Public** (alongside Live Class and Self Paced; Compliance stays unchanged).
|
||||
2. Allow anonymous browsing of a public course's full tree (sections, lessons, exercises) with clean readable URLs.
|
||||
3. Keep the implementation deliberately small: no auth gating on public courses, no server-side submissions, no analytics, no donations, no live viewer counts.
|
||||
4. Give creators a single customizable **Callout** to drive viewers somewhere (main site, sign-up, paid course, etc.).
|
||||
5. Design the lesson surface as a focused, distraction-free reading/watching experience.
|
||||
|
||||
## Non-Goals (v1)
|
||||
- Partial lesson locking (first X lessons free)
|
||||
- Per-lesson public/private toggles
|
||||
- Anonymous quiz attempts with delayed signup
|
||||
Everything below is explicitly out of scope for this PRD:
|
||||
- Guest vs authenticated distinction on public courses (everyone is treated the same)
|
||||
- Progress tracking (server, database, or localStorage)
|
||||
- Server-side exercise submissions and scoring
|
||||
- Guest-to-account conversion / progress merge
|
||||
- Analytics, guest events, conversion funnels
|
||||
- Live viewer counts / "people learning now"
|
||||
- Donations / Stripe payment links on public courses
|
||||
- Public-courses directory or landing-page tab
|
||||
- Feature flag / phased rollout gating
|
||||
- Downloadable asset gating
|
||||
- Discussion / community for public courses
|
||||
- Per-lesson public/private toggle beyond the existing lock state
|
||||
- Public course certificates
|
||||
- Revenue sharing for public courses
|
||||
|
||||
## User Roles & Permissions
|
||||
## User Roles
|
||||
|
||||
### 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
|
||||
Public courses have **one** viewer role: everyone. Authentication state does not change what a viewer can do on a public course — a logged-in user visiting a public course is treated identically to an anonymous visitor.
|
||||
|
||||
### 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
|
||||
### Course Creator / Admin
|
||||
- Selects course type at creation: **Live Class | Self Paced | Public** (Compliance remains a separate flow).
|
||||
- Can convert an existing course between types (behavior of conversion is not changed by this PRD).
|
||||
- When the course type is `PUBLIC`:
|
||||
- The question-type picker is filtered to **only auto-gradable** question types.
|
||||
- Per-item slugs (lesson / exercise) are editable; otherwise they are auto-generated from the title.
|
||||
- A **Callout** settings panel is available (title, description, button label, button URL).
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### 1. Course Type Selection
|
||||
|
||||
#### At Course Creation
|
||||
`COURSE_TYPE` enum today is `['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']` with default `'LIVE_CLASS'`. This PRD changes it to:
|
||||
|
||||
```
|
||||
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
|
||||
enum COURSE_TYPE = ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE', 'PUBLIC']
|
||||
default = 'SELF_PACED'
|
||||
```
|
||||
|
||||
#### 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
|
||||
Changing the default from `LIVE_CLASS` to `SELF_PACED` is intentional.
|
||||
|
||||
### 2. Anonymous Content Access
|
||||
A public course must still have `isPublished = true` to be reachable at its public URL. An unpublished public course behaves like any other unpublished course (not accessible).
|
||||
|
||||
#### Lesson View (Guest Mode)
|
||||
### 2. Only Auto-Gradable Question Types in Public Courses
|
||||
|
||||
Public courses are graded **entirely on the client**. The question-type registry already exposes an `autoGradable: boolean` flag on every question type.
|
||||
|
||||
- When the course type is `PUBLIC`, the question-type picker only shows question types with `autoGradable === true`.
|
||||
- The API rejects attempts to attach a non-auto-gradable question to an exercise belonging to a public course.
|
||||
- If a course is converted **into** `PUBLIC` and it already contains non-auto-gradable questions, the conversion is blocked with a clear error listing the offending questions. (No silent data loss.)
|
||||
|
||||
### 3. Lock State (reuse existing)
|
||||
|
||||
Public courses reuse the existing `lesson.isUnlocked` flag (and the existing `exercise.isUnlocked` flag) as the gate for "free vs gated" content.
|
||||
|
||||
- `isUnlocked = true` → content renders normally.
|
||||
- `isUnlocked = false` → the content area is replaced by the course's **Callout** (see §5). The sidebar row for a locked item still renders, with a lock icon next to the number.
|
||||
|
||||
Since every visitor is treated as a guest, there is no "sign in to unlock" path. A locked item is a call-to-action surface for the creator's callout.
|
||||
|
||||
### 4. Public URLs and Slugs
|
||||
|
||||
#### Routes
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 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] │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
(org-site)/course/[courseSlug] # course overview: tree of sections/lessons/exercises
|
||||
(org-site)/course/[courseSlug]/lesson/[itemSlug] # a single lesson or exercise page
|
||||
```
|
||||
|
||||
#### 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 |
|
||||
The URL segment is always `/lesson/` whether the item is a lesson or an exercise. The `itemSlug` resolves to one or the other.
|
||||
|
||||
### 3. Conversion Nudges (Soft Gates)
|
||||
#### Slug storage
|
||||
- Add `slug VARCHAR` to `lesson` and `exercise` tables.
|
||||
- Slugs are **unique per course** across both `lesson` and `exercise` (they share the same URL segment).
|
||||
- On lesson / exercise create, slug is auto-generated from the title (kebab-case, collision-resolved by appending `-2`, `-3`, ...).
|
||||
- On title rename, slug is **not** automatically updated (URLs are stable).
|
||||
- When the course type is `PUBLIC`, creators can edit the slug from the lesson / exercise settings UI.
|
||||
- When the course type is not `PUBLIC`, slugs are still generated and stored (so converting a course to `PUBLIC` later works) but are not surfaced in the UI.
|
||||
|
||||
#### 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] │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
#### Slug format
|
||||
- 1–80 chars, `[a-z0-9]` with single `-` separators, no leading/trailing `-`.
|
||||
- Validated on both client and server with a shared Zod schema.
|
||||
|
||||
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]
|
||||
```
|
||||
### 5. Data Loading Strategy
|
||||
|
||||
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]
|
||||
```
|
||||
- `GET (org-site) /course/[courseSlug]` loads the **full tree** server-side: course metadata, sections, lessons, exercises, slugs, titles, order, and `isUnlocked`. No lesson bodies, no exercise question bodies.
|
||||
- `GET (org-site) /course/[courseSlug]/lesson/[itemSlug]` loads the **content** of the single item server-side (video URL, lesson body, or exercise questions with answer keys). This render is SSR for SEO.
|
||||
- The tree is not re-fetched when the client navigates between lesson URLs — it is passed down through the shared `+layout.server.ts`.
|
||||
|
||||
#### 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
|
||||
### 6. Lesson Page Layout
|
||||
|
||||
### 4. Anonymous Progress (localStorage)
|
||||
Reference designs in `prd/public-courses/design/`:
|
||||
- `lesson-page-reference.png` — overall layout
|
||||
- `lesson-page-full.png` — full composition (hero video + title + body)
|
||||
- `sidebar-active-state.png` — active lesson state in the sidebar
|
||||
|
||||
Two-column, non-collapsible layout. No hamburger toggle, no accordion on the sidebar, no hide/show. Sidebar is always visible on desktop.
|
||||
|
||||
#### Left sidebar (navigation)
|
||||
- Grouped by the course's **sections** (existing `courseSection` / lesson-section concept).
|
||||
- Each section renders:
|
||||
- A small, muted section heading (e.g. "AI Foundations"). Plain text — not a button, not collapsible.
|
||||
- A vertical list of that section's lessons and exercises, in their existing `order`.
|
||||
- Each row contains:
|
||||
- A **square number badge** on the left with the per-section index (1, 2, 3, …). Numbering resets at the start of each section and is shared across lessons + exercises (they interleave by `order`).
|
||||
- The item title to the right of the badge.
|
||||
- If the item's `isUnlocked === false`, a lock icon replaces the number badge or sits next to it (final placement: TBD with design).
|
||||
- The active item (matching the current `itemSlug`) renders with:
|
||||
- **Primary color** (the app's `--primary` token, *not* the hard-coded orange in the screenshots) filling the number badge background, white number text.
|
||||
- **Primary color** title text.
|
||||
- A subtle primary-colored vertical accent bar on the far left edge (outside the sidebar content area, aligned with the row).
|
||||
- Hover state (non-active rows):
|
||||
- Number badge gets a slightly darker muted fill.
|
||||
- Title text gets a subtle color shift (muted-foreground → foreground).
|
||||
- Cursor is pointer.
|
||||
- No icons on normal rows, no progress rings, no status dots. Keep it text-first.
|
||||
|
||||
#### Right content column
|
||||
Vertical order, top to bottom:
|
||||
1. **Video** (if the item has one) — full-width within the content column, rounded corners, single centered play affordance. Omitted entirely if no video (no empty placeholder).
|
||||
2. **Section label** — small, muted text above the title (the name of the section this item belongs to).
|
||||
3. **Item title** — large, prominent heading.
|
||||
4. **Body** — prose / rich text for lessons, or the rendered question UI for exercises. Readable line length, generous line-height.
|
||||
5. **Callout** (see §7) — rendered as an inline card at the bottom of the body, only if the course has a callout configured.
|
||||
|
||||
If the item is **locked** (`isUnlocked = false`), the body and video are replaced by the callout card rendered in a larger, centered variant; steps 1–4 above are skipped.
|
||||
|
||||
No top banner, no hero image above the video, no breadcrumb. Minimal page chrome.
|
||||
|
||||
#### Responsive
|
||||
|
||||
**Desktop (≥ `lg`)**
|
||||
- Two columns: sidebar fixed width on the left, content column fluid on the right.
|
||||
- Sidebar is always visible (not collapsible).
|
||||
- No bottom navigation bar.
|
||||
|
||||
**Mobile / tablet (< `lg`)**
|
||||
- The left sidebar is **hidden**.
|
||||
- A **fixed bottom navigation bar** is pinned to the viewport. It has three regions:
|
||||
- **Left**: previous-item button (arrow-left icon). Navigates to the previous item in the flattened, section-ordered sequence. Disabled visually at the first item.
|
||||
- **Center**: large tap target. Shows the current item's position (e.g. the per-section number badge + the item title, or "3 / 12"). Tapping opens a bottom sheet.
|
||||
- **Right**: next-item button (arrow-right icon). Disabled visually at the last item.
|
||||
- The bottom sheet (built on `@cio/ui/base/drawer` — vaul-based) slides up from the bottom and contains the **full sidebar tree**: every section with its heading and every numbered row. The sheet supports the native drag-to-dismiss gesture ("swiper from bottom up"). The current item is visually active in the sheet. Tapping a row navigates and auto-closes the sheet.
|
||||
- The content column takes the full viewport width minus the bottom bar's height. The page adds bottom padding equal to the bar's height so the callout / body never sits under the bar.
|
||||
- The bottom bar uses `--primary` for the center button's accent (matching the sidebar active state) and solid surface background with a top hairline border.
|
||||
|
||||
**Shared**
|
||||
- "Prev / next sequence" is the flat `ORDER BY sections.order, items.order` across all items (lessons + exercises interleaved). Locked items are part of the sequence — tapping prev/next into a locked item still navigates; the locked state is then rendered in the content column as described in §6.
|
||||
- Keyboard shortcuts on desktop: `←` / `→` also move through this sequence (nice-to-have, not required for v1 acceptance).
|
||||
|
||||
#### Colors
|
||||
All accent colors in the sidebar must resolve to the app's **primary** color token (`--primary`), not the hard-coded orange visible in the reference screenshots. The screenshots are from Cursor Learn and are included purely as a layout reference.
|
||||
|
||||
### 7. Creator Callout (per-course setting)
|
||||
|
||||
Each public course has an optional **Callout** configured by the creator:
|
||||
|
||||
#### 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"
|
||||
}
|
||||
type CourseCallout = {
|
||||
title: string; // short, 1 line
|
||||
description: string; // 1–3 lines
|
||||
buttonLabel: string; // e.g. "Read more on my site"
|
||||
buttonUrl: string; // absolute URL, validated
|
||||
} | null;
|
||||
```
|
||||
|
||||
#### 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
|
||||
- Stored as a nullable JSONB column on `course` (e.g. `callout`).
|
||||
- Configured from the course settings page (only visible when course type is `PUBLIC`).
|
||||
- Rendered on the public lesson page:
|
||||
- As an inline card at the bottom of the lesson/exercise body (on every item).
|
||||
- As the full replacement when an item is locked (larger centered variant).
|
||||
- Opens `buttonUrl` in a new tab (`target="_blank" rel="noopener"`).
|
||||
|
||||
### 5. Landing Page Integration
|
||||
When `callout` is `null`, no callout is rendered anywhere (lesson still reads cleanly without it; locked items show a plain "This lesson is locked" state).
|
||||
|
||||
#### New "Public Courses" Section
|
||||
```
|
||||
Our Courses
|
||||
### 8. Client-Side Grading
|
||||
|
||||
[Live Classes] [Self Paced] [Public Courses]
|
||||
For exercises on a public course:
|
||||
- The server ships full question data including answer keys to the client.
|
||||
- Grading happens entirely in the browser — correctness, explanations, and "you got X / Y correct" all computed locally.
|
||||
- No submission is sent to the server.
|
||||
- No attempt history is recorded.
|
||||
- Refreshing the page resets the exercise state.
|
||||
|
||||
Explore free courses - no signup required to start learning!
|
||||
This is an intentional trade-off: public courses are marketing surfaces, so answer keys are not considered sensitive.
|
||||
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ [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] │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
### 9. SEO
|
||||
|
||||
#### Course Card Badges
|
||||
- **Live Class**: "📅 Live" badge (red)
|
||||
- **Self Paced**: "📚 Self Paced" badge (blue)
|
||||
- **Public Course**: "🌐 Public" badge (green/teal)
|
||||
- Public course lesson URLs render server-side (SvelteKit SSR).
|
||||
- Each lesson page sets:
|
||||
- `<title>` = item title + course title
|
||||
- `<meta name="description">` = course description or first ~150 chars of the item body
|
||||
- `<link rel="canonical">` = the public URL
|
||||
- `og:type=article`, `og:title`, `og:description`, `og:image` (course banner)
|
||||
- Locked items still render meta tags but the body is replaced by the callout; the `<description>` falls back to the course description.
|
||||
- No `robots` meta restrictions beyond what the org already sets globally.
|
||||
|
||||
### 6. URL Structure
|
||||
## Data Model Changes
|
||||
|
||||
### `course` table
|
||||
- `type` enum: add `'PUBLIC'`, change default to `'SELF_PACED'`.
|
||||
- New column `callout JSONB NULL` (shape described in §7).
|
||||
|
||||
### `lesson` table
|
||||
- New column `slug VARCHAR NULL`.
|
||||
- Backfill existing rows with kebab-cased titles (collision-resolved per course).
|
||||
- After backfill, `slug` becomes `NOT NULL` in a follow-up migration.
|
||||
- Existing `public BOOLEAN` column is deprecated — not referenced in new code. Column is **not** dropped in this PRD (to keep the migration small).
|
||||
|
||||
### `exercise` table
|
||||
- New column `slug VARCHAR NULL`, same treatment as `lesson.slug`.
|
||||
|
||||
### Slug uniqueness
|
||||
- Enforced at the application layer: a slug is unique per-course across the union of `lesson.slug` and `exercise.slug`.
|
||||
- A DB-level constraint is not added in v1 (would require a materialized view or a trigger across two tables); application code must check before insert/update.
|
||||
|
||||
## API Changes
|
||||
|
||||
### New / modified endpoints (high level)
|
||||
|
||||
```
|
||||
# Public course lesson (accessible to all)
|
||||
/course/{courseSlug}/lesson/{lessonSlug}
|
||||
GET (public) /org-site/course/:courseSlug
|
||||
→ Returns course metadata + tree (sections, lessons, exercises with slugs, titles, order, isUnlocked).
|
||||
→ No auth required when course.type === 'PUBLIC' && course.isPublished === true.
|
||||
|
||||
# Same URL works for both guest and authenticated
|
||||
# Server renders content based on auth state
|
||||
# No separate /public/ prefix needed
|
||||
GET (public) /org-site/course/:courseSlug/item/:itemSlug
|
||||
→ Returns either a lesson content payload or an exercise payload (with answer keys).
|
||||
→ Response is a single shape with a discriminator field (e.g. `kind: 'lesson' | 'exercise'`).
|
||||
→ No auth required under the same conditions as above.
|
||||
|
||||
PATCH (authed) /courses/:courseId/lessons/:lessonId
|
||||
→ Accepts `slug` field (validated against slug regex and per-course uniqueness).
|
||||
PATCH (authed) /courses/:courseId/exercises/:exerciseId
|
||||
→ Accepts `slug` field (same validation).
|
||||
|
||||
POST (authed) /courses/:courseId/exercises/:exerciseId/questions
|
||||
PATCH (authed) /courses/:courseId/exercises/:exerciseId/questions/:questionId
|
||||
→ Reject non-auto-gradable question types when the parent course is PUBLIC.
|
||||
|
||||
PATCH (authed) /courses/:courseId
|
||||
→ Accepts `callout` object (nullable).
|
||||
→ Accepts `type` (validates transition, including the "no non-auto-gradable questions exist" check when converting to PUBLIC).
|
||||
```
|
||||
|
||||
### 7. SEO & Sharing
|
||||
Per the repo's single-response-shape rule: the item endpoint returns one response shape with a `kind` discriminator; client narrows on `kind`.
|
||||
|
||||
#### 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">
|
||||
```
|
||||
## UI Components Needed
|
||||
|
||||
#### Social Sharing Card
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ │
|
||||
│ [Course Thumbnail] │
|
||||
│ │
|
||||
├────────────────────────┤
|
||||
│ Python Basics │
|
||||
│ Free course - start │
|
||||
│ learning instantly! │
|
||||
│ │
|
||||
│ 🌐 classroomio.com │
|
||||
└────────────────────────┘
|
||||
```
|
||||
- `PublicCourseShell` (layout wrapper: sidebar + content)
|
||||
- `PublicCourseSidebar` (the numbered, sectioned nav — reusable for any item count)
|
||||
- `PublicCourseSidebarRow` (number badge + title + lock state)
|
||||
- `PublicLessonView` (video + section label + title + body + callout)
|
||||
- `PublicExerciseView` (exercise questions rendered for client-side grading)
|
||||
- `PublicCourseCallout` (inline + locked variants)
|
||||
- `CourseCalloutSettings` (creator settings panel, only visible when course is PUBLIC)
|
||||
- `LessonSlugField` / `ExerciseSlugField` (editable slug inputs in item settings, only visible when course is PUBLIC)
|
||||
|
||||
## Technical Requirements
|
||||
Existing components (`QuestionRenderer`, `QuestionList`, etc.) are reused for exercise rendering, configured with the client-grading mode.
|
||||
|
||||
### Data Model Changes
|
||||
## Storybook-First Build
|
||||
|
||||
#### Course Table
|
||||
```typescript
|
||||
// Add to existing course schema
|
||||
type CourseType = 'LIVE_CLASS' | 'SELF_PACED' | 'PUBLIC';
|
||||
The public course UI is built as a set of Storybook components and consumed by the `(org-site)` routes. Stories are the primary development + review surface — reviewers test desktop and mobile layouts in Storybook before the routes are wired up.
|
||||
|
||||
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
|
||||
}
|
||||
```
|
||||
Required stories (at minimum):
|
||||
- `PublicCourseShell` — desktop and mobile viewports, with and without callout.
|
||||
- `PublicCourseSidebar` — default state, with a locked item, with many sections.
|
||||
- `PublicCourseSidebarRow` — default, hover, active, locked variants as individual stories.
|
||||
- `PublicCourseBottomNav` — first item (prev disabled), middle item, last item (next disabled).
|
||||
- `PublicCourseMobileSheet` — closed and open states (open state is the default story for review).
|
||||
- `PublicLessonView` — with video, without video, long body, short body.
|
||||
- `PublicExerciseView` — unanswered, mid-answer, after-grade (client-side).
|
||||
- `PublicCourseCallout` — inline and full (locked) variants, and "no callout configured" fallback.
|
||||
- `PublicCoursePage` — composed story showing the full page; run with Storybook's viewport addon to verify desktop and mobile side-by-side.
|
||||
|
||||
#### 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
|
||||
All stories must resolve colors through the app's theme tokens (`--primary`, etc.), not hard-coded values.
|
||||
|
||||
### API Changes
|
||||
## Open Items (non-blocking)
|
||||
|
||||
#### 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
|
||||
- Precise placement of the lock icon in a sidebar row (inside the number badge, replacing it, or beside it) — design review later.
|
||||
- Center button content in the mobile bottom bar — "3 / 12" vs "Section • item title" vs both — finalized during Storybook review.
|
||||
|
||||
## 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
|
||||
}
|
||||
```
|
||||
1. A creator can set a course's type to `PUBLIC` and the course becomes reachable at `(org-site)/course/[slug]` with no sign-in required.
|
||||
2. The question-type picker hides non-auto-gradable types when the course type is `PUBLIC`.
|
||||
3. Lesson and exercise slugs are auto-generated and editable (for public courses).
|
||||
4. Locked items on a public course render the creator's callout in place of content; unlocked items render normally.
|
||||
5. Exercises on public courses are graded entirely in the browser with no server submission.
|
||||
6. The lesson page layout matches the reference designs: non-collapsible sidebar, per-section numbering, content column order (video → section label → title → body → callout).
|
||||
7. All accent colors resolve to `--primary`, not a hard-coded orange.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,40 +1,34 @@
|
||||
# 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
|
||||
|
||||
1. **Course type**: add `PUBLIC` to `COURSE_TYPE` enum, change default to `SELF_PACED`.
|
||||
2. **Everyone is a guest**: no auth-based branching on public courses.
|
||||
3. **Content access**: entire tree + per-item content readable without auth when `course.type === 'PUBLIC'` and `course.isPublished === true`.
|
||||
4. **Gating**: reuse existing `lesson.isUnlocked` / `exercise.isUnlocked`. Locked items show the creator callout in place of content.
|
||||
5. **Grading**: exercises on public courses are graded entirely client-side. Answer keys ship to the client. No server submissions.
|
||||
6. **Question types**: only `autoGradable` question types allowed in public courses. Enforced in UI picker, API writes, and type-conversion checks.
|
||||
7. **URLs**: `(org-site)/course/[courseSlug]` (tree) and `(org-site)/course/[courseSlug]/lesson/[itemSlug]` (content). Single `/lesson/` segment for both lessons and exercises.
|
||||
8. **Slugs**: new `slug` columns on `lesson` and `exercise`. Auto-generated from title, editable when course is `PUBLIC`. Unique per course across lesson + exercise.
|
||||
9. **Callout**: new `callout` JSONB on `course` with `{ title, description, buttonLabel, buttonUrl }`. Optional.
|
||||
10. **Out of scope**: analytics, donations, live viewer counts, feature flag, localStorage progress, directory tab, discussion, progress tracking of any kind.
|
||||
|
||||
## 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 A — Data model + validation (Days 1–2)
|
||||
Schema changes, migrations, slug backfill, validation schemas.
|
||||
|
||||
### 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 B — API: public read endpoints + slug + callout (Days 3–5)
|
||||
Public-facing GET endpoints for the tree and item content; PATCH endpoints accept slug and callout; guard writes against non-auto-gradable question types.
|
||||
|
||||
### 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 C — Creator UI (Days 6–8)
|
||||
Course type picker adds `PUBLIC`; question-type picker filters by `autoGradable` for public courses; slug field in lesson / exercise settings; callout settings panel.
|
||||
|
||||
### 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 D — Public course UI in Storybook (Days 9–12)
|
||||
**Storybook first.** Build every component with its stories in `packages/ui` + `packages/storybook` so desktop and mobile layouts can be reviewed in isolation before wiring up the routes. Includes: `PublicCourseShell`, `PublicCourseSidebar`, `PublicCourseSidebarRow`, `PublicCourseBottomNav`, `PublicCourseMobileSheet`, `PublicLessonView`, `PublicExerciseView`, `PublicCourseCallout`, plus a composed `PublicCoursePage` story.
|
||||
|
||||
### Phase E: Analytics & Polish (Days 11-12)
|
||||
- Implement guest analytics tracking
|
||||
- Add conversion funnel metrics
|
||||
- Testing and edge cases
|
||||
### Phase E — Wire routes + polish (Days 13–14)
|
||||
Mount the storybook-ready components at `(org-site)/course/[slug]` and `(org-site)/course/[slug]/lesson/[itemSlug]`. SEO meta tags, canonical URLs, locked-state rendering, a11y pass, manual QA against the reference designs.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,190 +36,164 @@
|
||||
|
||||
| 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 |
|
||||
| PC-A1 | DB | Add `PUBLIC` to `COURSE_TYPE` enum; change default to `SELF_PACED` | `packages/db/src/schema.ts`, new migration | None | Migration applied; enum accepts `PUBLIC`; default is `SELF_PACED` |
|
||||
| PC-A2 | DB | Add `callout JSONB NULL` column on `course` | `packages/db/src/schema.ts`, migration | PC-A1 | Column exists; type matches `CourseCallout` shape |
|
||||
| PC-A3 | DB | Add `slug VARCHAR NULL` to `lesson` and `exercise` | `packages/db/src/schema.ts`, migration | None | Columns exist on both tables |
|
||||
| PC-A4 | DB | Backfill slugs for existing `lesson` and `exercise` rows (kebab-case from title, collision-resolved per course) | Migration SQL or one-shot script | PC-A3 | All rows have non-null slug; uniqueness holds per course |
|
||||
| PC-A5 | DB | Follow-up migration making `lesson.slug` and `exercise.slug` `NOT NULL` | Migration | PC-A4 | Columns are `NOT NULL` |
|
||||
| PC-A6 | DB queries | Update lesson / exercise queries to return `slug` | `packages/db/src/queries/course/**/*.ts` | PC-A3 | Queries project the new column |
|
||||
| PC-A7 | Utils | Slug validator (Zod: kebab-case, 1–80 chars) | `packages/utils/src/validation/shared/slug.ts` | None | Schema exported and unit-tested |
|
||||
| PC-A8 | Utils | Callout validator (Zod for `{ title, description, buttonLabel, buttonUrl }`) | `packages/utils/src/validation/course/callout.ts` | None | Schema exported; URL validated |
|
||||
| PC-A9 | Utils | Extend course validation schemas to accept `type: 'PUBLIC'`, `callout`, and `slug` fields on lesson / exercise | `packages/utils/src/validation/course/**`, `lesson/**`, `exercise/**` | PC-A7, PC-A8 | Schemas accept new fields |
|
||||
| PC-A10 | Question types | Expose an `autoGradable` filter helper for pickers (if not already exported) | `packages/question-types/src/question-type-capabilities.ts` | None | Helper available via `isAutoGradableQuestionType` / registry filter |
|
||||
| PC-B1 | API service | `slugFromTitle(title, existingSlugsInCourse)` utility (generate + collision-resolve) | `apps/api/src/services/course/slug.ts` | PC-A7 | Pure function; unit tests cover collision cases |
|
||||
| PC-B2 | API service | On lesson / exercise create, auto-generate `slug` if not provided | `apps/api/src/services/course/lesson.ts`, `apps/api/src/services/exercise/exercise.ts` | PC-B1 | New items get slugs automatically |
|
||||
| PC-B3 | API service | On lesson / exercise update, accept slug edit; enforce per-course uniqueness across lessons + exercises | Same services | PC-B1 | Conflicting slugs are rejected with a clear error code |
|
||||
| PC-B4 | API service | Block non-auto-gradable question types on public courses (create + update) | `apps/api/src/services/exercise/exercise.ts` | PC-A10 | Attempts rejected with `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` |
|
||||
| PC-B5 | API service | `convertCourseType(courseId, newType)` — when converting **to** `PUBLIC`, reject if any attached question is non-auto-gradable; return list of offenders | `apps/api/src/services/course/course.ts` | PC-A10 | Conversion blocked with actionable error; passes when all questions are auto-gradable |
|
||||
| PC-B6 | API service | Accept `callout` on course update | `apps/api/src/services/course/course.ts` | PC-A8 | Callout round-trips through create/update/read |
|
||||
| PC-B7 | API route | `GET /org-site/course/:courseSlug` (no auth) returns course + section tree + item metadata incl. slugs | `apps/api/src/routes/org-site/course.ts` (new file) | PC-A6 | Anonymous requests succeed when course is public + published; 404 otherwise |
|
||||
| PC-B8 | API route | `GET /org-site/course/:courseSlug/item/:itemSlug` (no auth) returns one of two shapes with discriminator `kind: 'lesson' \| 'exercise'` | Same router | PC-B7 | Returns lesson body or exercise payload (with answer keys) |
|
||||
| PC-B9 | API route | Register the new router in `app.ts`; ensure `authMiddleware` is NOT applied to this router | `apps/api/src/app.ts` | PC-B7, PC-B8 | Router mounted; smoke test as anonymous user succeeds |
|
||||
| PC-C1 | Dashboard | Add `PUBLIC` option to course type picker at course creation | `apps/dashboard/src/lib/features/course/components/course-create/*` (existing) | PC-A9 | Creator can pick `Public` and create a course with `type: 'PUBLIC'` |
|
||||
| PC-C2 | Dashboard | Filter question-type picker to `autoGradable` only when `course.type === 'PUBLIC'` | `apps/dashboard/src/lib/features/ui/question/constants.ts`, `packages/ui/src/custom/question-type-picker/*` | PC-A10 | Non-auto-gradable types are hidden from the picker in public courses |
|
||||
| PC-C3 | Dashboard | Add slug editor to lesson and exercise settings (visible only when course is `PUBLIC`) | Lesson / exercise settings components under `features/course/components/...` | PC-A7 | Creator can edit slug; server validation errors surface inline |
|
||||
| PC-C4 | Dashboard | Add **Callout Settings** panel on the course settings page (visible only when `PUBLIC`) | `apps/dashboard/src/lib/features/course/components/settings/callout-settings.svelte` (new) | PC-B6 | Creator can set / clear title, description, button label, button URL |
|
||||
| PC-C5 | Dashboard | Warn in UI when switching a course **to** `PUBLIC` with non-auto-gradable questions (surface server error nicely) | Course settings type picker | PC-B5 | Clear error modal listing offending items |
|
||||
| PC-D1 | UI | `PublicCourseShell` — responsive two-column layout. Desktop: sidebar + content. Mobile: hides sidebar, reserves bottom padding for the bottom bar. Accepts `sidebar`, `bottomNav`, `main` slots. | `packages/ui/src/custom/public-course/shell.svelte` | None | Component renders correctly at both breakpoints; bottom padding reserved on mobile |
|
||||
| PC-D2 | UI | `PublicCourseSidebarRow` — number badge + title + lock indicator. Variants: default, hover, active (uses `--primary`), locked. | `packages/ui/src/custom/public-course/sidebar-row.svelte` | None | Matches `design/sidebar-active-state.png`; no hard-coded orange |
|
||||
| PC-D3 | UI | `PublicCourseSidebar` — sections with muted headings and their rows. Per-section numbering resets across lessons + exercises interleaved by `order`. | `packages/ui/src/custom/public-course/sidebar.svelte` | PC-D2 | Sections render in order; numbering resets per section |
|
||||
| PC-D4 | UI | `PublicCourseBottomNav` — mobile-only fixed bottom bar with prev / center / next. Emits `onPrev`, `onNext`, `onCenterTap`. Disables prev / next at boundaries. | `packages/ui/src/custom/public-course/bottom-nav.svelte` | None | Three regions render; disabled states work; `--primary` used for accent |
|
||||
| PC-D5 | UI | `PublicCourseMobileSheet` — vaul-based drawer that slides up with the full sidebar tree inside. Drag-to-dismiss. Auto-closes on item tap. | `packages/ui/src/custom/public-course/mobile-sheet.svelte` (uses `@cio/ui/base/drawer`) | PC-D3 | Opens on center-tap; swipe-down dismisses; tapping a row navigates + closes |
|
||||
| PC-D6 | UI | `PublicLessonView` — video → section label → title → body → inline callout. Video slot omitted entirely when no video. | `packages/ui/src/custom/public-course/lesson-view.svelte` | PC-D8 | Matches `design/lesson-page-full.png` |
|
||||
| PC-D7 | UI | `PublicExerciseView` — renders exercise questions in client-grading mode. Uses existing question-type render contract. No server submit. | `packages/ui/src/custom/public-course/exercise-view.svelte` | None | Exercises grade locally; "you got X / Y" displayed; refresh resets state |
|
||||
| PC-D8 | UI | `PublicCourseCallout` — inline variant (below body) and full variant (replaces body for locked items). | `packages/ui/src/custom/public-course/callout.svelte` | None | Both variants render; button opens URL in new tab |
|
||||
| PC-D9 | Storybook | `PublicCourseSidebarRow` stories: default, hover, active, locked. | `packages/storybook/src/molecules/public-course/sidebar-row.stories.svelte` | PC-D2 | All four variants selectable |
|
||||
| PC-D10 | Storybook | `PublicCourseSidebar` stories: default, with locked item, many sections. | `...sidebar.stories.svelte` | PC-D3 | Three scenarios render correctly |
|
||||
| PC-D11 | Storybook | `PublicCourseBottomNav` stories: first item (prev disabled), middle item, last item (next disabled). | `...bottom-nav.stories.svelte` | PC-D4 | Three position states selectable |
|
||||
| PC-D12 | Storybook | `PublicCourseMobileSheet` stories: closed, open (default), open-at-section. | `...mobile-sheet.stories.svelte` | PC-D5 | Stories demonstrate drag-to-dismiss and row-tap close |
|
||||
| PC-D13 | Storybook | `PublicLessonView` stories: with video, without video, long body, short body. | `...lesson-view.stories.svelte` | PC-D6 | Four stories render |
|
||||
| PC-D14 | Storybook | `PublicExerciseView` stories: unanswered, mid-answer, after-grade. | `...exercise-view.stories.svelte` | PC-D7 | Three stories render; after-grade shows correct/incorrect marks |
|
||||
| PC-D15 | Storybook | `PublicCourseCallout` stories: inline, full (locked), no-callout fallback. | `...callout.stories.svelte` | PC-D8 | Three variants render |
|
||||
| PC-D16 | Storybook | **Composed** `PublicCoursePage` story — mounts `Shell` + `Sidebar` + `LessonView` + `BottomNav`. Use Storybook's viewport addon to switch desktop / tablet / mobile. This is the primary review surface for design sign-off. | `...public-course-page.stories.svelte` | PC-D1..PC-D8 | Desktop shows two columns (no bottom bar); mobile hides sidebar and shows bottom bar; center-tap opens sheet |
|
||||
| PC-D17 | Dashboard types | Frontend types for public tree + item responses (inferred from API). | `apps/dashboard/src/lib/features/public-course/utils/types.ts` (new feature folder) | PC-B7, PC-B8 | Typed responses usable in routes |
|
||||
| PC-D18 | Dashboard route | `(org-site)/course/[slug]/+layout.server.ts` — SSR fetch of the tree, exposes to child routes. | New file | PC-D17 | Tree available via layout data |
|
||||
| PC-D19 | Dashboard route | `(org-site)/course/[slug]/+page.svelte` — overview page linking into items by slug. | New file | PC-D18 | Renders overview; links use `itemSlug` |
|
||||
| PC-D20 | Dashboard route | `(org-site)/course/[slug]/lesson/[itemSlug]/+page.server.ts` — SSR fetch for item content. | New file | PC-B8 | Server-rendered content on first paint |
|
||||
| PC-D21 | Dashboard route | `(org-site)/course/[slug]/lesson/[itemSlug]/+page.svelte` — composes `Shell` + `Sidebar` + `LessonView` / `ExerciseView` + `BottomNav` + `MobileSheet`. | New file | PC-D16, PC-D20 | Page matches the composed Storybook story behavior |
|
||||
| PC-D22 | Dashboard | Locked-item rendering: when `isUnlocked === false`, swap body for the full-variant callout. | Inside `+page.svelte` or container component | PC-D8 | Locked items render the callout; sidebar shows lock icon |
|
||||
| PC-D23 | Dashboard | Wire prev / next navigation (both desktop keyboard shortcuts and mobile bottom bar) to `goto()` the next / previous `itemSlug`. | New util: `features/public-course/utils/navigation.ts` | PC-D4 | Arrow keys and bottom bar both navigate; disabled at boundaries |
|
||||
| PC-E1 | SEO | Set `<title>`, `<meta description>`, canonical, and OG tags on public lesson pages. | `+page.svelte` head blocks | PC-D21 | View-source shows correct tags |
|
||||
| PC-E2 | i18n | All new user-facing strings in `apps/dashboard/src/lib/utils/translations/en.json` (callout settings, locked-state fallback text, prev / next labels for a11y, etc.). | translations file | PC-C4, PC-D22 | No hardcoded English in new components |
|
||||
| PC-E3 | a11y | Keyboard + screen-reader review: sidebar rows are links, bottom-nav buttons have labels, sheet traps focus while open. | All new components | PC-D16, PC-D21 | `axe` passes on the composed story and on a live page |
|
||||
| PC-E4 | Tests | Service + route tests for public GET endpoints, slug uniqueness, non-auto-gradable rejection, type-conversion guard. | `apps/api/src/**/__tests__/public-course*.test.ts` | PC-B* | Tests pass |
|
||||
|
||||
---
|
||||
|
||||
## API Changes
|
||||
## Schema Snippets
|
||||
|
||||
### New Endpoints
|
||||
### Migration: enum + default + columns
|
||||
|
||||
```typescript
|
||||
// GET /courses/public
|
||||
// List all public courses (no auth required)
|
||||
// Returns: Course[] filtered by type='PUBLIC' and status='PUBLISHED'
|
||||
```sql
|
||||
-- Add PUBLIC to the existing COURSE_TYPE enum
|
||||
ALTER TYPE "COURSE_TYPE" ADD VALUE IF NOT EXISTS 'PUBLIC';
|
||||
|
||||
// POST /analytics/guest-event
|
||||
// Track guest activity (privacy-safe, no PII)
|
||||
// Body: { event: string, courseId: string, lessonId?: string, timestamp: number }
|
||||
// Returns: 204 No Content
|
||||
-- Change default
|
||||
ALTER TABLE "course" ALTER COLUMN "type" SET DEFAULT 'SELF_PACED';
|
||||
|
||||
-- Callout
|
||||
ALTER TABLE "course" ADD COLUMN "callout" JSONB;
|
||||
|
||||
-- Slugs (nullable first; NOT NULL after backfill)
|
||||
ALTER TABLE "lesson" ADD COLUMN "slug" VARCHAR;
|
||||
ALTER TABLE "exercise" ADD COLUMN "slug" VARCHAR;
|
||||
```
|
||||
|
||||
### Modified Endpoints
|
||||
Backfill (pseudo):
|
||||
|
||||
```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
|
||||
```ts
|
||||
// For each course, iterate lessons + exercises ordered by order,
|
||||
// generate kebab-case slugs from titles, resolve collisions per course
|
||||
// by appending -2, -3, ... across the union of both tables.
|
||||
```
|
||||
|
||||
Follow-up migration:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "lesson" ALTER COLUMN "slug" SET NOT NULL;
|
||||
ALTER TABLE "exercise" ALTER COLUMN "slug" SET NOT NULL;
|
||||
```
|
||||
|
||||
(No per-course unique constraint in SQL; enforced in application code.)
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
## API Response Shapes
|
||||
|
||||
### 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
|
||||
### Tree response
|
||||
|
||||
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
|
||||
```ts
|
||||
GET /org-site/course/:courseSlug
|
||||
// 200
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
course: {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
bannerImage: string | null;
|
||||
callout: {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
} | null;
|
||||
};
|
||||
sections: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
items: Array<
|
||||
| { kind: 'lesson'; id: string; slug: string; title: string; order: number; isUnlocked: boolean; hasVideo: boolean }
|
||||
| { kind: 'exercise'; id: string; slug: string; title: string; order: number; isUnlocked: boolean }
|
||||
>;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
// 404 if course is not public or not published
|
||||
```
|
||||
|
||||
---
|
||||
### Item response (single type, discriminator)
|
||||
|
||||
## 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;
|
||||
```ts
|
||||
GET /org-site/course/:courseSlug/item/:itemSlug
|
||||
// 200
|
||||
{
|
||||
success: true,
|
||||
data:
|
||||
| {
|
||||
kind: 'lesson';
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
sectionTitle: string;
|
||||
isUnlocked: boolean;
|
||||
video: { type: 'youtube' | 'upload' | 'generic' | 'google_drive'; link: string } | null;
|
||||
body: string; // rich text / HTML
|
||||
}
|
||||
| {
|
||||
kind: 'exercise';
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
sectionTitle: string;
|
||||
isUnlocked: boolean;
|
||||
questions: ExercisePayloadWithAnswerKeys; // uses question-types render contract
|
||||
};
|
||||
}
|
||||
// 404 if course not public/published or item not found
|
||||
```
|
||||
|
||||
---
|
||||
@@ -233,76 +201,70 @@ interface ConversionEvent {
|
||||
## 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'
|
||||
- [ ] `COURSE_TYPE` enum includes `PUBLIC`.
|
||||
- [ ] `course.type` default is `SELF_PACED` for new rows.
|
||||
- [ ] `course.callout` column exists and accepts null or full object.
|
||||
- [ ] `lesson.slug` and `exercise.slug` exist, backfilled, and `NOT NULL`.
|
||||
- [ ] Slug validator rejects invalid shapes (spaces, uppercase, >80 chars).
|
||||
|
||||
### 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
|
||||
- [ ] `GET /org-site/course/:courseSlug` returns the tree anonymously for public + published courses.
|
||||
- [ ] `GET /org-site/course/:courseSlug/item/:itemSlug` returns lesson or exercise content with `kind` discriminator.
|
||||
- [ ] Anonymous request on a non-public or unpublished course returns 404.
|
||||
- [ ] Creating / updating a question with a non-auto-gradable type on a PUBLIC course is rejected with a clear error.
|
||||
- [ ] Converting a course to `PUBLIC` fails when non-auto-gradable questions exist; error lists the offending items.
|
||||
- [ ] Lesson / exercise PATCH accepts `slug` edits and enforces per-course uniqueness across both tables.
|
||||
|
||||
### 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
|
||||
- [ ] Creator can select `PUBLIC` as a course type.
|
||||
- [ ] Question-type picker hides non-auto-gradable types when the course is `PUBLIC`.
|
||||
- [ ] Slug field is visible and editable on lesson / exercise settings (only when course is `PUBLIC`).
|
||||
- [ ] Callout settings panel is visible on the course settings page (only when `PUBLIC`) and round-trips the callout object.
|
||||
|
||||
### Phase D
|
||||
- [ ] Landing page has Public Courses tab
|
||||
- [ ] Course cards show correct type badge
|
||||
- [ ] Public courses list endpoint works without auth
|
||||
- [ ] Every new component in `packages/ui/src/custom/public-course/*` has at least one Storybook story (listed in PC-D9..PC-D16).
|
||||
- [ ] The composed `PublicCoursePage` story renders correctly at desktop, tablet, and mobile viewports (via Storybook's viewport addon).
|
||||
- [ ] Desktop: two columns, no bottom bar, sidebar visible.
|
||||
- [ ] Mobile: sidebar hidden, fixed bottom bar visible with prev / center / next regions; center tap opens a drag-dismissible bottom sheet containing the full sidebar tree; tapping a row navigates and closes the sheet.
|
||||
- [ ] Sidebar numbers reset per section; active state uses `--primary`; hover state matches the reference designs.
|
||||
- [ ] Locked items render the creator callout in place of content; sidebar shows a lock indicator.
|
||||
- [ ] Exercises grade entirely client-side; no network request on answer submit.
|
||||
- [ ] No hard-coded orange anywhere; all accents resolve to `--primary`.
|
||||
- [ ] `(org-site)/course/[slug]` renders the course overview.
|
||||
- [ ] `(org-site)/course/[slug]/lesson/[itemSlug]` matches the composed Storybook story on a live browser.
|
||||
- [ ] Anonymous visitors see the same experience regardless of auth state.
|
||||
|
||||
### 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
|
||||
- [ ] View-source on a public lesson page shows the item title, description, canonical URL, and OG tags.
|
||||
- [ ] All user-facing strings are translation keys.
|
||||
- [ ] Keyboard navigation works on desktop (`←` / `→` move through items); bottom-nav buttons have a11y labels; the mobile sheet traps focus while open.
|
||||
- [ ] `axe` passes on both the composed Storybook story and a live public lesson page.
|
||||
- [ ] API tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Build all packages
|
||||
pnpm --filter @cio/utils build
|
||||
pnpm --filter @cio/question-types build
|
||||
pnpm --filter @cio/db build
|
||||
pnpm --filter @cio/api build
|
||||
pnpm --filter @cio/ui build
|
||||
pnpm --filter @cio/dashboard build
|
||||
|
||||
# Run tests
|
||||
pnpm --filter @cio/api test public-courses
|
||||
|
||||
# Type check
|
||||
pnpm --filter @cio/api test public-course
|
||||
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
|
||||
## Risks & Mitigations
|
||||
|
||||
| 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" |
|
||||
| Slug collisions during backfill | Two-pass backfill script: generate all candidates, resolve collisions per course, then write. Wrap in a transaction and dry-run in staging first. |
|
||||
| Answer keys shipped to the client | Accepted trade-off (public courses are marketing surfaces). Documented in the PRD. |
|
||||
| Converting a course with many non-auto-gradable questions to `PUBLIC` is painful | Server error lists every offending question with its exercise + section so the creator can fix them in one pass. |
|
||||
| SEO duplicate content between `(app)/courses/[id]/lessons/[lessonId]` and the new public URL | Canonical URL on public pages points at the `(org-site)` URL. `(app)` URLs remain auth-gated and are not indexed. |
|
||||
| Hard-coded orange in screenshots leaking into the build | Explicit rule in the PRD: all accents use `--primary`. Reviewer should grep new components for raw orange hex values. |
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
---
|
||||
name: Study from Source
|
||||
overview: Add a Study from Source course type and source-study activity type that convert documents, webpages, and pasted text into structured learning experiences with AI-generated lessons, checkpoints, quizzes, and completion evidence.
|
||||
todos:
|
||||
- id: prd-review
|
||||
content: Finalize scope, naming, source limits, and pricing gates
|
||||
status: pending
|
||||
- id: source-model
|
||||
content: Add persistent source, extraction, and source segment data model
|
||||
status: pending
|
||||
- id: course-type
|
||||
content: Add STUDY_FROM_SOURCE course type and course creation flow
|
||||
status: pending
|
||||
- id: activity-type
|
||||
content: Add source-study activity type for smaller document or webpage readings
|
||||
status: pending
|
||||
- id: ingestion
|
||||
content: Implement upload, URL fetch, pasted text ingestion, extraction, and source snapshots
|
||||
status: pending
|
||||
- id: ai-planning
|
||||
content: Implement AI outline generation, lesson breakdown, and teacher review workflow
|
||||
status: pending
|
||||
- id: publish
|
||||
content: Publish approved generated outlines into lessons and exercises
|
||||
status: pending
|
||||
- id: learner-reader
|
||||
content: Build learner source reader with progress, active-time, and completion tracking
|
||||
status: pending
|
||||
- id: assessments
|
||||
content: Generate source-grounded quiz questions and completion rules
|
||||
status: pending
|
||||
- id: reporting
|
||||
content: Add teacher progress, completion evidence, and source coverage reports
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Study from Source PRD
|
||||
|
||||
## Status
|
||||
|
||||
- Draft
|
||||
|
||||
## Date
|
||||
|
||||
- April 30, 2026
|
||||
|
||||
## Purpose
|
||||
|
||||
Add **Study from Source** as both:
|
||||
|
||||
1. a **course type** for turning larger source material into a full course with AI-generated lessons and assessments
|
||||
2. an **activity type** for assigning a smaller source, such as a blog post or short PDF, inside an existing course and verifying that learners studied it
|
||||
|
||||
The product should support uploaded documents, webpage links, and pasted text. The AI agent should analyze the source, break it into teachable units, generate testable checkpoints, and produce learner-facing content that can be reviewed before publish.
|
||||
|
||||
Detailed engineering breakdown: [implementation-plan.md](/Users/rotimibest/_pros/classroomio/prd/study-from-source/implementation-plan.md).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Teachers, trainers, and operators often start from existing material: policies, manuals, papers, webpages, blog posts, SOPs, and handbooks. Today they must manually convert that material into lessons and exercises, and there is no strong way to verify that a learner actually studied a standalone source.
|
||||
|
||||
Current gaps:
|
||||
|
||||
- no dedicated course type for source-derived learning
|
||||
- no source ingestion model that handles documents, URLs, and pasted text under one workflow
|
||||
- no persistent source segmentation or source-to-lesson traceability
|
||||
- no AI workflow that proposes lessons from source material before creating course content
|
||||
- no smaller source-study activity for "read this and prove you understood it"
|
||||
- no completion evidence that combines source engagement, quiz performance, and optional attestation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Study from Source** lets a teacher provide source material and choose one of two paths:
|
||||
|
||||
| Path | Best For | Output |
|
||||
| --- | --- | --- |
|
||||
| `STUDY_FROM_SOURCE` course type | Long documents, manuals, policy packs, research papers, detailed webpages, documentation hubs | A full self-paced course with generated lessons, optional sections, quizzes, and source coverage reports |
|
||||
| Source-study activity type | Short PDFs, articles, policy updates, blog posts, one-off reading assignments | One activity that displays the source and generates a quiz covering the key subject matter |
|
||||
|
||||
Both paths reuse the same source ingestion, extraction, AI analysis, quiz generation, and completion evidence primitives.
|
||||
|
||||
## Product Positioning
|
||||
|
||||
### Course Type
|
||||
|
||||
The course type is for cases where the source itself should become the course.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Turn this onboarding handbook into a course."
|
||||
- "Make a course from this security policy."
|
||||
- "Break this research paper into lessons with checks for understanding."
|
||||
- "Create training from this webpage documentation."
|
||||
|
||||
The AI output is a reviewable course plan:
|
||||
|
||||
- course title and description
|
||||
- lessons generated from source chapters or inferred topics
|
||||
- optional sections if the source is large enough
|
||||
- lesson content summaries or explanations
|
||||
- lesson-level quizzes or checkpoints
|
||||
- final quiz if configured
|
||||
- source coverage map
|
||||
|
||||
### Activity Type
|
||||
|
||||
The activity type is for cases where the source belongs inside another course.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Read this blog post and answer questions."
|
||||
- "Review this updated policy PDF."
|
||||
- "Study this external article before the next lesson."
|
||||
|
||||
The AI output is one learner activity:
|
||||
|
||||
- source viewer
|
||||
- optional short summary
|
||||
- generated quiz covering the key subject matter
|
||||
- completion rule based on engagement and score
|
||||
- teacher-facing evidence that the learner opened, progressed through, and passed the check
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add a first-class `STUDY_FROM_SOURCE` course type.
|
||||
2. Add a reusable source-study activity type for smaller sources.
|
||||
3. Support source inputs from uploaded files, webpage URLs, and pasted text.
|
||||
4. Preserve source snapshots so generated content remains auditable even if a webpage changes.
|
||||
5. Generate an editable course plan before creating lessons or exercises.
|
||||
6. Create lessons from source segments, not from unstructured whole-document prompts.
|
||||
7. Generate quiz questions that are grounded in specific source segments.
|
||||
8. Track learner engagement with the source and completion evidence.
|
||||
9. Keep teacher review mandatory before publishing AI-generated course content in v1.
|
||||
10. Reuse existing lessons, sections, exercises, submissions, and course progress where possible.
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Proctored exams or identity verification
|
||||
- Full plagiarism detection for written reflections
|
||||
- Perfect extraction of complex tables, formulas, scanned PDFs, or diagrams
|
||||
- Multi-source knowledge graph or semantic search across an entire org
|
||||
- Real-time collaborative editing of generated outlines
|
||||
- Automatic publishing without teacher review
|
||||
- Full website crawling beyond explicitly provided URLs
|
||||
- Video ingestion, YouTube transcript ingestion, or podcast/audio ingestion
|
||||
- Compliance retake/expiry lifecycle; that belongs to the compliance course type
|
||||
|
||||
## Core Product Decisions
|
||||
|
||||
1. The feature is named **Study from Source**.
|
||||
2. The course type should be `STUDY_FROM_SOURCE`.
|
||||
3. The activity type should be source-study, not document-study, because URLs and pasted text are first-class inputs.
|
||||
4. Course generation creates lessons by default. Sections are optional and only used when the source naturally needs grouping.
|
||||
5. The activity type does not create multiple lessons. It displays one source and generates one quiz/checkpoint over the key subject matter.
|
||||
6. AI-generated output is always draft/reviewable before learner-facing publish in v1.
|
||||
7. Source snapshots and extracted text are persisted for auditability.
|
||||
8. Completion should be based on evidence, not only an "opened" event.
|
||||
|
||||
## Current-State Audit
|
||||
|
||||
| Capability | Current State | Notes |
|
||||
| --- | --- | --- |
|
||||
| Course types | `SELF_PACED`, `LIVE_CLASS`, `COMPLIANCE` | Add `STUDY_FROM_SOURCE` to the existing enum |
|
||||
| Course content | Sections, lessons, and exercises exist | Course type can publish into current content model |
|
||||
| Course grouping | Course metadata supports `isContentGroupingEnabled` | Use sections only when needed |
|
||||
| Lesson documents | Lessons already store document attachments | Useful for source display, but not enough for source segmentation |
|
||||
| Assets | Asset manager supports `document` kind | Reuse for uploaded source files |
|
||||
| AI assistant document upload | Exists for course chat uploads | Useful reference, but Study from Source needs persistent source records |
|
||||
| AI course assistant PRD | Covers in-course chat and document-to-course planning | Study from Source should reuse patterns but has a dedicated product surface |
|
||||
| Course import draft schema | Exists for structured sections, lessons, lesson languages, and exercises | Good foundation for publish pipeline |
|
||||
| Exercises/submissions | Existing exercises and auto-grading exist | Reuse for generated quizzes where possible |
|
||||
| Compliance course type | Already exists in schema/code | Distinct from Study from Source; only combine later if needed |
|
||||
|
||||
Data references:
|
||||
|
||||
- [packages/db/src/schema.ts](/Users/rotimibest/_pros/classroomio/packages/db/src/schema.ts)
|
||||
- [packages/utils/src/validation/course-import/course-import.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/course-import/course-import.ts)
|
||||
- [apps/api/src/services/agent/document.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/agent/document.ts)
|
||||
- [apps/api/src/routes/agent/upload.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/agent/upload.ts)
|
||||
- [prd/ai-course-assistant [DONE]/README.md](/Users/rotimibest/_pros/classroomio/prd/ai-course-assistant%20[DONE]/README.md)
|
||||
- [prd/compliance-training-platform [DONE]/README.md](/Users/rotimibest/_pros/classroomio/prd/compliance-training-platform%20[DONE]/README.md)
|
||||
|
||||
## User Personas
|
||||
|
||||
### Persona A: Teacher
|
||||
|
||||
Wants to turn a PDF, article, or textbook chapter into lessons and quizzes without manually copying content into the course builder.
|
||||
|
||||
### Persona B: Training Operator
|
||||
|
||||
Wants employees to study policies, handbooks, SOPs, and process docs, then prove they understood the material.
|
||||
|
||||
### Persona C: Course Creator
|
||||
|
||||
Wants to convert long-form source material into a polished self-paced course with clear lessons, objectives, and assessments.
|
||||
|
||||
### Persona D: Learner
|
||||
|
||||
Wants a clear reading/study experience with short checks for understanding and transparent progress.
|
||||
|
||||
## Jobs To Be Done
|
||||
|
||||
1. Upload or link source material and turn it into structured learning content.
|
||||
2. Break source content into sensible lessons.
|
||||
3. Generate testable questions from the important points in the source.
|
||||
4. Review and edit AI-generated structure before publishing.
|
||||
5. Assign a small source reading inside an existing course.
|
||||
6. Verify that a learner engaged with the source and understood key material.
|
||||
7. See which source parts are covered by lessons and quizzes.
|
||||
|
||||
## Source Types
|
||||
|
||||
### Supported in v1
|
||||
|
||||
| Source Type | Input | Requirements |
|
||||
| --- | --- | --- |
|
||||
| File upload | PDF, DOCX, PPTX, TXT, Markdown | File stored as asset where possible; extracted text persisted |
|
||||
| Webpage URL | Single URL | Fetch server-side, extract readable article/body content, store snapshot |
|
||||
| Pasted text | Plain text or Markdown | Store directly with source metadata |
|
||||
|
||||
### Out of Scope for v1
|
||||
|
||||
- website crawling across many pages
|
||||
- private webpages requiring login
|
||||
- scanned PDF OCR
|
||||
- Google Drive document import
|
||||
- YouTube/video transcript ingestion
|
||||
- ZIP packages or SCORM
|
||||
|
||||
## Source Lifecycle
|
||||
|
||||
```
|
||||
Draft source created
|
||||
↓
|
||||
File uploaded / URL fetched / text pasted
|
||||
↓
|
||||
Text extracted and normalized
|
||||
↓
|
||||
Source segmented
|
||||
↓
|
||||
AI generates outline or activity quiz
|
||||
↓
|
||||
Teacher reviews and edits
|
||||
↓
|
||||
Teacher publishes
|
||||
↓
|
||||
Learners study source-derived content
|
||||
↓
|
||||
Progress, quiz score, and completion evidence recorded
|
||||
```
|
||||
|
||||
## Feature Requirements
|
||||
|
||||
### 1. Shared Source Model
|
||||
|
||||
#### Purpose
|
||||
|
||||
Create one persistence model for documents, webpages, and pasted text so both the course type and activity type use the same ingestion and AI pipeline.
|
||||
|
||||
#### Requirements
|
||||
|
||||
**SFS-SRC-1: Source Table**
|
||||
|
||||
Add a `study_source` table:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | Source ID |
|
||||
| `organizationId` | uuid | Owning org |
|
||||
| `courseId` | uuid nullable | Course associated with source |
|
||||
| `lessonId` | uuid nullable | Set when source is used by one lesson/activity |
|
||||
| `activityId` | uuid nullable | Set if a separate activity table is introduced |
|
||||
| `createdByProfileId` | uuid | Creator |
|
||||
| `type` | enum | `file`, `webpage`, `text` |
|
||||
| `status` | enum | `draft`, `extracting`, `ready`, `failed`, `archived` |
|
||||
| `title` | text | Display title |
|
||||
| `sourceUrl` | text nullable | Original webpage URL |
|
||||
| `assetId` | uuid nullable | Uploaded asset reference |
|
||||
| `mimeType` | text nullable | Source MIME type |
|
||||
| `rawText` | text | Extracted or pasted text |
|
||||
| `normalizedText` | text | Cleaned learner/AI text |
|
||||
| `wordCount` | integer | Source word count |
|
||||
| `metadata` | jsonb | Fetch/extraction metadata |
|
||||
| `createdAt` / `updatedAt` | timestamp | Audit fields |
|
||||
|
||||
**SFS-SRC-2: Source Segment Table**
|
||||
|
||||
Add a `study_source_segment` table:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | Segment ID |
|
||||
| `sourceId` | uuid | Parent source |
|
||||
| `parentSegmentId` | uuid nullable | Optional hierarchy |
|
||||
| `order` | integer | Segment ordering |
|
||||
| `kind` | enum | `chapter`, `section`, `paragraph`, `page`, `inferred_topic` |
|
||||
| `title` | text nullable | Heading or inferred title |
|
||||
| `text` | text | Segment text |
|
||||
| `startLocator` | jsonb | Page, heading, text offset, URL fragment |
|
||||
| `endLocator` | jsonb | Page, heading, text offset, URL fragment |
|
||||
| `metadata` | jsonb | AI/extraction details |
|
||||
|
||||
**SFS-SRC-3: Generated Draft Tables**
|
||||
|
||||
Add `study_source_generation_draft` for reviewable AI output before publish:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | Draft ID |
|
||||
| `sourceId` | uuid | Primary source |
|
||||
| `courseId` | uuid nullable | Existing course for activity generation |
|
||||
| `lessonId` | uuid nullable | Existing lesson if regenerating an activity |
|
||||
| `createdByProfileId` | uuid | Creator |
|
||||
| `kind` | enum | `course_outline`, `activity_quiz`, `lesson_regeneration`, `quiz_regeneration` |
|
||||
| `status` | enum | `draft`, `approved`, `publishing`, `published`, `failed`, `archived` |
|
||||
| `inputSettings` | jsonb | Generation settings |
|
||||
| `output` | jsonb | Proposed outline, lesson content, questions, and mappings |
|
||||
| `warnings` | jsonb | Truncation, extraction, or quality warnings |
|
||||
| `publishedCourseId` | uuid nullable | Created course when kind is `course_outline` |
|
||||
| `publishedLessonId` | uuid nullable | Created/updated lesson when relevant |
|
||||
| `publishedExerciseId` | uuid nullable | Created quiz/exercise when relevant |
|
||||
| `createdAt` / `updatedAt` | timestamp | Audit fields |
|
||||
|
||||
This keeps AI output reviewable and versioned before it touches learner-facing content.
|
||||
|
||||
**SFS-SRC-4: Source Coverage Table**
|
||||
|
||||
Add `study_source_coverage` to map source segments to generated content:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | Coverage ID |
|
||||
| `sourceId` | uuid | Source |
|
||||
| `segmentId` | uuid | Source segment |
|
||||
| `courseId` | uuid | Course |
|
||||
| `lessonId` | uuid nullable | Generated lesson |
|
||||
| `exerciseId` | uuid nullable | Generated exercise |
|
||||
| `questionId` | uuid nullable | Generated question |
|
||||
| `coverageType` | enum | `lesson_content`, `quiz_question`, `summary`, `reflection_prompt` |
|
||||
| `confidence` | numeric nullable | AI/source mapping confidence |
|
||||
| `createdAt` | timestamp | Audit field |
|
||||
|
||||
**SFS-SRC-5: Source Snapshot**
|
||||
|
||||
For webpage sources:
|
||||
|
||||
- store the fetched URL, final resolved URL, HTTP status, content type, fetched timestamp, and readable extracted content
|
||||
- do not rely on live webpage content for learner playback or quiz auditing
|
||||
- show the original URL as the source citation
|
||||
|
||||
**SFS-SRC-6: Source Limits**
|
||||
|
||||
Initial limits:
|
||||
|
||||
- uploaded file max: 10MB for Study from Source
|
||||
- extracted text max per source: 150,000 characters in v1
|
||||
- URL fetch timeout: 15 seconds
|
||||
- URL max response size: 10MB
|
||||
- one URL per source in v1
|
||||
|
||||
If the source is truncated, the UI must make that visible before generation.
|
||||
|
||||
### 2. Study from Source Course Type
|
||||
|
||||
#### Purpose
|
||||
|
||||
Let teachers create a complete course from one or more source materials.
|
||||
|
||||
#### Requirements
|
||||
|
||||
**SFS-COURSE-1: Course Type Enum**
|
||||
|
||||
- Add `STUDY_FROM_SOURCE` to `COURSE_TYPE`
|
||||
- It behaves like a self-paced course for learner delivery, progress, and exercises
|
||||
- It carries extra source-generation metadata in a dedicated table or course metadata
|
||||
|
||||
**SFS-COURSE-2: Course Creation Entry**
|
||||
|
||||
Add a course creation path:
|
||||
|
||||
```
|
||||
New Course -> Study from Source
|
||||
```
|
||||
|
||||
The teacher can:
|
||||
|
||||
- upload a source file
|
||||
- enter a webpage URL
|
||||
- paste source text
|
||||
- add a title and optional prompt/instructions
|
||||
- choose whether the AI should create lessons only or sections + lessons
|
||||
- choose quiz strategy: per lesson, final quiz only, or both
|
||||
|
||||
**SFS-COURSE-3: AI Outline Generation**
|
||||
|
||||
The system generates a proposed plan:
|
||||
|
||||
```json
|
||||
{
|
||||
"courseTitle": "Workplace Security Handbook",
|
||||
"courseDescription": "A source-based training course on core security policies.",
|
||||
"structure": {
|
||||
"usesSections": true,
|
||||
"sections": [
|
||||
{
|
||||
"title": "Access Control",
|
||||
"lessons": [
|
||||
{
|
||||
"title": "Password and MFA Requirements",
|
||||
"summary": "Explains password standards, MFA enrollment, and account recovery.",
|
||||
"sourceSegmentIds": ["segment_1", "segment_2"],
|
||||
"learningObjectives": [
|
||||
"Explain why MFA is required",
|
||||
"Identify acceptable password practices"
|
||||
],
|
||||
"assessment": {
|
||||
"questionCount": 4,
|
||||
"questionTypes": ["radio", "checkbox", "short_answer"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SFS-COURSE-4: Lessons Are the Primary Output**
|
||||
|
||||
- The course type creates lessons as the primary learner-facing units
|
||||
- Sections are optional
|
||||
- If the source has natural chapters or is long enough to need grouping, the AI should propose sections
|
||||
- If the source is moderate length, the AI should create a flat lesson list
|
||||
|
||||
**SFS-COURSE-5: Teacher Review**
|
||||
|
||||
Before publish, the teacher can:
|
||||
|
||||
- rename course, sections, and lessons
|
||||
- merge or split lessons
|
||||
- remove generated lessons
|
||||
- adjust quiz count and passing score
|
||||
- regenerate a lesson or quiz
|
||||
- view source segments behind each lesson
|
||||
- approve generation into real course content
|
||||
|
||||
**SFS-COURSE-6: Publish Behavior**
|
||||
|
||||
Publishing creates:
|
||||
|
||||
- course shell with type `STUDY_FROM_SOURCE`
|
||||
- optional sections
|
||||
- lessons with generated content in lesson language content
|
||||
- exercises attached to lessons or course as configured
|
||||
- questions/options using existing exercise models
|
||||
- source coverage records mapping source segments to lessons and questions
|
||||
|
||||
**SFS-COURSE-7: Completion Rule**
|
||||
|
||||
Default completion for Study from Source courses:
|
||||
|
||||
- all required lessons completed
|
||||
- required generated quizzes passed
|
||||
- optional final attestation checked if enabled
|
||||
|
||||
The course should reuse existing self-paced completion and auto-grading behavior where possible.
|
||||
|
||||
### 3. Source-Study Activity Type
|
||||
|
||||
#### Purpose
|
||||
|
||||
Let a teacher add one source reading/checkpoint inside an existing course without generating a whole course.
|
||||
|
||||
#### Product Difference From Course Type
|
||||
|
||||
| Capability | Course Type | Activity Type |
|
||||
| --- | --- | --- |
|
||||
| Input size | Larger source | Smaller source |
|
||||
| Output | Multiple lessons, optional sections, quizzes | One activity with source viewer and quiz |
|
||||
| Course structure change | Creates a course structure | Adds one item inside an existing course |
|
||||
| AI breakdown | Break into lessons | Extract key subject matter and quiz it |
|
||||
| Learner experience | Move through generated lessons | Read source, answer quiz, complete |
|
||||
|
||||
#### Requirements
|
||||
|
||||
**SFS-ACT-1: Activity Entry**
|
||||
|
||||
Add a content creation option:
|
||||
|
||||
```
|
||||
Add Content -> Study from Source
|
||||
```
|
||||
|
||||
This creates a source-study activity in the current course, either:
|
||||
|
||||
- as a specialized lesson variant in v1, or
|
||||
- as a new content type if the content model is expanded beyond section/lesson/exercise
|
||||
|
||||
Recommendation for v1:
|
||||
|
||||
- implement as a lesson with source-study metadata and an attached generated exercise
|
||||
- avoid adding a fourth content type until the product needs a generalized activity model
|
||||
- persist activity-specific configuration in a `study_source_activity` table linked to `lessonId`, `sourceId`, and generated `exerciseId`
|
||||
|
||||
Suggested `study_source_activity` fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | Activity ID |
|
||||
| `courseId` | uuid | Course |
|
||||
| `lessonId` | uuid | Lesson that renders the activity |
|
||||
| `sourceId` | uuid | Source used by the activity |
|
||||
| `exerciseId` | uuid nullable | Generated quiz/checkpoint |
|
||||
| `title` | text | Learner-facing activity title |
|
||||
| `status` | enum | `draft`, `published`, `archived` |
|
||||
| `requiredProgressPercent` | integer | Default 90 |
|
||||
| `minimumActiveTimeSeconds` | integer nullable | Optional engagement requirement |
|
||||
| `passingScorePercent` | integer | Default 80 |
|
||||
| `requiresAttestation` | boolean | Whether learner must attest |
|
||||
| `settings` | jsonb | Reader display and generation settings |
|
||||
| `createdAt` / `updatedAt` | timestamp | Audit fields |
|
||||
|
||||
**SFS-ACT-2: Activity Source Input**
|
||||
|
||||
Teacher can:
|
||||
|
||||
- upload a file
|
||||
- paste a webpage URL
|
||||
- paste text
|
||||
- set title
|
||||
- choose question count
|
||||
- choose passing score
|
||||
- enable or disable attestation
|
||||
|
||||
**SFS-ACT-3: AI Output**
|
||||
|
||||
The AI generates:
|
||||
|
||||
- short source summary for teacher review
|
||||
- key subject matter list
|
||||
- quiz questions covering the source
|
||||
- optional reflection prompt
|
||||
- source coverage map for each question
|
||||
|
||||
The activity does not break the source into multiple lessons.
|
||||
|
||||
**SFS-ACT-4: Learner Display**
|
||||
|
||||
Learner sees:
|
||||
|
||||
- source title and metadata
|
||||
- embedded source viewer or normalized readable text
|
||||
- progress indicator
|
||||
- generated quiz
|
||||
- optional attestation checkbox
|
||||
|
||||
For webpage sources, show the stored snapshot and link to the original source.
|
||||
|
||||
**SFS-ACT-5: Completion Rule**
|
||||
|
||||
Default completion:
|
||||
|
||||
- source opened
|
||||
- minimum source progress reached, default 90%
|
||||
- minimum active study time reached, default disabled for v1 unless configured
|
||||
- quiz submitted and passed
|
||||
- attestation checked if enabled
|
||||
|
||||
### 4. AI Generation Requirements
|
||||
|
||||
#### Purpose
|
||||
|
||||
Ensure AI output is source-grounded, reviewable, and useful for learning.
|
||||
|
||||
#### Requirements
|
||||
|
||||
**SFS-AI-1: Segment-First Generation**
|
||||
|
||||
The AI must operate on source segments, not only a full raw text blob. The pipeline:
|
||||
|
||||
1. extract text
|
||||
2. normalize text
|
||||
3. segment source
|
||||
4. generate outline or activity quiz from segments
|
||||
5. store mappings from generated output back to segments
|
||||
|
||||
**SFS-AI-2: Grounding**
|
||||
|
||||
Each generated lesson and question should reference source segment IDs.
|
||||
|
||||
This enables:
|
||||
|
||||
- source coverage reports
|
||||
- teacher review of generated claims
|
||||
- future regeneration of a single lesson/question
|
||||
- auditability when a learner disputes a quiz item
|
||||
|
||||
**SFS-AI-3: Question Types**
|
||||
|
||||
v1 should generate only question types that can be reliably auto-graded:
|
||||
|
||||
- radio
|
||||
- checkbox
|
||||
- true/false if available in the question-type system
|
||||
- short answer only if answer matching/grading is reliable enough
|
||||
|
||||
Manual grading question types are optional and should be disabled by default.
|
||||
|
||||
**SFS-AI-4: Quality Controls**
|
||||
|
||||
The generation prompt should require:
|
||||
|
||||
- no questions that rely on unstated assumptions
|
||||
- no questions about trivial wording unless configured
|
||||
- answer explanations for teacher review
|
||||
- no unsupported citations
|
||||
- direct source segment mapping for every question
|
||||
|
||||
**SFS-AI-5: Regeneration**
|
||||
|
||||
Teachers can regenerate:
|
||||
|
||||
- entire course outline
|
||||
- one lesson
|
||||
- one lesson quiz
|
||||
- one activity quiz
|
||||
|
||||
Regeneration preserves the source record and creates a new generated draft version.
|
||||
|
||||
### 5. Learner Tracking and Evidence
|
||||
|
||||
#### Purpose
|
||||
|
||||
Track enough behavior to support a reasonable "studied it" claim without pretending that passive reading telemetry proves comprehension.
|
||||
|
||||
#### Requirements
|
||||
|
||||
**SFS-TRACK-1: Study Events**
|
||||
|
||||
Add `study_source_event` table:
|
||||
|
||||
| Event | Description |
|
||||
| --- | --- |
|
||||
| `opened` | Learner opened source/activity |
|
||||
| `segment_viewed` | Learner viewed a source segment |
|
||||
| `progress_updated` | Learner reached a progress threshold |
|
||||
| `active_time_recorded` | Active time heartbeat |
|
||||
| `quiz_started` | Quiz opened |
|
||||
| `quiz_submitted` | Quiz submitted |
|
||||
| `attestation_checked` | Learner confirmed they studied the source |
|
||||
| `completed` | Completion rule satisfied |
|
||||
|
||||
**SFS-TRACK-2: Progress Aggregates**
|
||||
|
||||
Add `study_source_progress` table:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `sourceId` | Source |
|
||||
| `courseId` | Course |
|
||||
| `lessonId` | Lesson/activity if applicable |
|
||||
| `exerciseId` | Generated quiz if applicable |
|
||||
| `profileId` | Learner |
|
||||
| `progressPercent` | Source progress |
|
||||
| `activeTimeSeconds` | Active foreground time |
|
||||
| `quizScorePercent` | Latest or best quiz score |
|
||||
| `completedAt` | Completion timestamp |
|
||||
| `evidence` | JSON summary of completion signals |
|
||||
|
||||
**SFS-TRACK-3: Evidence Language**
|
||||
|
||||
Teacher-facing UI should say:
|
||||
|
||||
- "Completed study requirements"
|
||||
- "Passed source check"
|
||||
- "Viewed 92% of source and scored 85%"
|
||||
|
||||
Avoid claiming:
|
||||
|
||||
- "guaranteed they read every word"
|
||||
- "proved they understood everything"
|
||||
|
||||
### 6. Reporting
|
||||
|
||||
#### Requirements
|
||||
|
||||
**SFS-REPORT-1: Course Source Coverage**
|
||||
|
||||
For Study from Source courses, show:
|
||||
|
||||
- total source segments
|
||||
- segments covered by lessons
|
||||
- segments covered by quiz questions
|
||||
- uncovered segments
|
||||
- generated lessons per source
|
||||
|
||||
**SFS-REPORT-2: Learner Completion Report**
|
||||
|
||||
For both course and activity paths, show:
|
||||
|
||||
- learner
|
||||
- status
|
||||
- source progress
|
||||
- active time
|
||||
- quiz score
|
||||
- attempts
|
||||
- completion timestamp
|
||||
- attestation status
|
||||
|
||||
**SFS-REPORT-3: Export**
|
||||
|
||||
Support CSV export of learner completion evidence.
|
||||
|
||||
## UX Flows
|
||||
|
||||
### Flow 1: Create Study from Source Course
|
||||
|
||||
```
|
||||
1. Teacher clicks New Course
|
||||
2. Selects Study from Source
|
||||
3. Adds source: upload, URL, or pasted text
|
||||
4. Chooses generation settings
|
||||
5. System extracts source and shows status
|
||||
6. AI proposes course outline
|
||||
7. Teacher reviews, edits, and approves
|
||||
8. System creates course, lessons, and exercises
|
||||
9. Teacher previews and publishes course
|
||||
```
|
||||
|
||||
### Flow 2: Add Source-Study Activity
|
||||
|
||||
```
|
||||
1. Teacher opens existing course content editor
|
||||
2. Clicks Add Content
|
||||
3. Selects Study from Source
|
||||
4. Adds short source
|
||||
5. Chooses question count and passing score
|
||||
6. AI generates source summary and quiz
|
||||
7. Teacher reviews and publishes
|
||||
8. Learner studies source and completes quiz
|
||||
9. Teacher sees completion evidence
|
||||
```
|
||||
|
||||
### Flow 3: Learner Completes Activity
|
||||
|
||||
```
|
||||
1. Learner opens source-study activity
|
||||
2. Source viewer records opened event
|
||||
3. Learner progresses through content
|
||||
4. Learner answers generated quiz
|
||||
5. System grades quiz
|
||||
6. If completion rule is satisfied, activity is complete
|
||||
7. Completion evidence appears in teacher report
|
||||
```
|
||||
|
||||
## Proposed API Surface
|
||||
|
||||
### Source Management
|
||||
|
||||
- `POST /study-sources`
|
||||
- `GET /study-sources/:sourceId`
|
||||
- `POST /study-sources/:sourceId/extract`
|
||||
- `POST /study-sources/:sourceId/segment`
|
||||
- `DELETE /study-sources/:sourceId`
|
||||
|
||||
### Course Type Generation
|
||||
|
||||
- `POST /course/study-from-source/drafts`
|
||||
- `GET /course/study-from-source/drafts/:draftId`
|
||||
- `PUT /course/study-from-source/drafts/:draftId`
|
||||
- `POST /course/study-from-source/drafts/:draftId/regenerate`
|
||||
- `POST /course/study-from-source/drafts/:draftId/publish`
|
||||
|
||||
### Activity Generation
|
||||
|
||||
- `POST /course/:courseId/source-study-activities`
|
||||
- `GET /course/:courseId/source-study-activities/:activityId`
|
||||
- `PUT /course/:courseId/source-study-activities/:activityId`
|
||||
- `POST /course/:courseId/source-study-activities/:activityId/generate-quiz`
|
||||
- `POST /course/:courseId/source-study-activities/:activityId/publish`
|
||||
|
||||
### Learner Tracking
|
||||
|
||||
- `POST /course/:courseId/study-sources/:sourceId/events`
|
||||
- `GET /course/:courseId/study-sources/:sourceId/progress`
|
||||
- `GET /course/:courseId/study-sources/:sourceId/report`
|
||||
|
||||
Route names can be adjusted during implementation to match existing course route grouping.
|
||||
|
||||
## Validation Schemas
|
||||
|
||||
Add schemas under:
|
||||
|
||||
- `packages/utils/src/validation/study-source/study-source.ts`
|
||||
- `packages/utils/src/validation/study-from-source/study-from-source.ts`
|
||||
|
||||
Core schema groups:
|
||||
|
||||
- source create
|
||||
- URL source create
|
||||
- file source finalize
|
||||
- pasted text source create
|
||||
- course generation settings
|
||||
- course generated draft update
|
||||
- source-study activity create/update
|
||||
- study event create
|
||||
- report query filters
|
||||
|
||||
## Dashboard Feature Placement
|
||||
|
||||
Add frontend modules under:
|
||||
|
||||
- `apps/dashboard/src/lib/features/study-source/`
|
||||
- `apps/dashboard/src/lib/features/course/components/content/` for the create entry
|
||||
- `apps/dashboard/src/lib/features/course/pages/` if a dedicated source report page is needed
|
||||
|
||||
Request/response types must live in the feature `utils/types.ts` file and be inferred from API route types.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Shared Source Foundation
|
||||
|
||||
1. Add enums and DB tables for source, segment, event, progress, and generated draft metadata.
|
||||
2. Add query modules under `packages/db/src/queries/study-source/`.
|
||||
3. Add validation schemas.
|
||||
4. Add source create/get/delete routes.
|
||||
5. Reuse asset upload and document extraction where possible.
|
||||
|
||||
### Phase 2: Ingestion
|
||||
|
||||
1. Support file upload source.
|
||||
2. Support pasted text source.
|
||||
3. Support webpage URL fetch and readable-text extraction.
|
||||
4. Store source snapshots and extraction metadata.
|
||||
5. Add extraction status UI.
|
||||
|
||||
### Phase 3: Source Segmentation
|
||||
|
||||
1. Segment by document headings/pages where available.
|
||||
2. Segment webpages by headings and readable sections.
|
||||
3. Use AI-assisted inferred segmentation only when deterministic structure is weak.
|
||||
4. Store segment locators for citations and coverage.
|
||||
|
||||
### Phase 4: Study from Source Course Type
|
||||
|
||||
1. Add `STUDY_FROM_SOURCE` to course type enum.
|
||||
2. Add course creation option.
|
||||
3. Build AI outline generation endpoint.
|
||||
4. Build teacher review UI.
|
||||
5. Publish approved drafts into sections, lessons, lesson language content, and exercises.
|
||||
|
||||
### Phase 5: Source-Study Activity
|
||||
|
||||
1. Add `Study from Source` content creation option.
|
||||
2. Implement as a lesson variant with source metadata and generated exercise in v1.
|
||||
3. Build source reader UI.
|
||||
4. Generate and review activity quiz.
|
||||
5. Publish activity inside an existing course.
|
||||
|
||||
### Phase 6: Learner Tracking and Reports
|
||||
|
||||
1. Add event ingestion endpoint.
|
||||
2. Add progress aggregation service.
|
||||
3. Connect generated quiz submission to source completion.
|
||||
4. Add teacher reports and CSV export.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Course Type
|
||||
|
||||
1. Teacher can create a `STUDY_FROM_SOURCE` course from a PDF, webpage URL, or pasted text.
|
||||
2. AI proposes a reviewable course outline with lessons and optional sections.
|
||||
3. Teacher can edit the proposed outline before publish.
|
||||
4. Publishing creates real course content using existing lessons and exercises.
|
||||
5. Generated questions are attached to source segments.
|
||||
6. Learners can complete the generated course like a self-paced course.
|
||||
|
||||
### Activity Type
|
||||
|
||||
1. Teacher can add a source-study activity to an existing course.
|
||||
2. Activity can use a file, URL, or pasted text.
|
||||
3. AI generates a quiz that covers the key subject matter.
|
||||
4. Learner can view the source and complete the quiz.
|
||||
5. Completion requires progress and passing score by default.
|
||||
6. Teacher can view completion evidence per learner.
|
||||
|
||||
### Source Handling
|
||||
|
||||
1. Source snapshots are persisted.
|
||||
2. Webpage changes do not change an already-published activity or course.
|
||||
3. Truncation and extraction failures are visible to teachers.
|
||||
4. Source records are scoped to the organization and course permissions.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| AI generates inaccurate lesson content | Learners receive wrong material | Require teacher review, source mapping, and citations |
|
||||
| Webpages are dynamic or copyrighted | Snapshot may be incomplete or legally sensitive | Store metadata, show original URL, let orgs control use policy |
|
||||
| Source extraction fails on complex PDFs | Poor lesson generation | Show extraction preview and allow pasted text fallback |
|
||||
| Activity tracking overclaims reading | Trust issue | Use careful evidence language and combine telemetry with quiz score |
|
||||
| Adding a new content type is disruptive | Broad content model refactor | Implement activity as lesson variant in v1 |
|
||||
| Token cost is high for large sources | Slow or expensive generation | Segment source, estimate cost before generation, allow truncation controls |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should Study from Source require a paid plan, or only source upload/AI generation?
|
||||
2. Should URL fetching allow private/intranet URLs, or public URLs only?
|
||||
3. Should teachers be able to include multiple sources in one Study from Source course in v1?
|
||||
4. Should source-study activities appear as lessons, exercises, or a new content type in the learner sidebar?
|
||||
5. What is the default passing score: 70%, 80%, or inherited from course settings?
|
||||
6. Should generated questions include explanations visible to learners after submission?
|
||||
7. Should source snapshots be downloadable by learners, or only viewable in-app?
|
||||
@@ -0,0 +1,795 @@
|
||||
# Study from Source Implementation Plan
|
||||
|
||||
## Engineering Position
|
||||
|
||||
Study from Source should be built as a durable source-ingestion and generation pipeline, not as a direct "send file to AI" feature.
|
||||
|
||||
The system owns:
|
||||
|
||||
1. source intake
|
||||
2. storage
|
||||
3. deterministic extraction
|
||||
4. normalization
|
||||
5. segmentation
|
||||
6. AI generation from bounded source segments
|
||||
7. draft persistence
|
||||
8. teacher review
|
||||
9. publish into existing course primitives
|
||||
10. learner progress and completion evidence
|
||||
|
||||
The AI should not fetch arbitrary URLs, read private files directly, or decide what gets persisted. The AI receives normalized source segments and returns structured drafts that are validated before storage and again before publish.
|
||||
|
||||
## Task Separation
|
||||
|
||||
### Deterministic System Tasks
|
||||
|
||||
These tasks should be regular backend services and workers:
|
||||
|
||||
| Task | Owner | Notes |
|
||||
| --- | --- | --- |
|
||||
| Auth and permission checks | API routes/middleware | Organization-scoped, course-team-scoped where relevant |
|
||||
| File upload and asset registration | API service | Reuse asset pipeline where possible |
|
||||
| Webpage fetch | API worker | Server-side fetch with strict SSRF protections |
|
||||
| Text extraction | Extraction service | PDF/DOCX/PPTX/TXT/Markdown/HTML |
|
||||
| Text normalization | Extraction service | Remove boilerplate, normalize whitespace, preserve headings |
|
||||
| Source segmentation | Segmentation service | Deterministic first, AI-assisted only for weak structure |
|
||||
| Source snapshot persistence | DB/query layer | Source must remain stable after publish |
|
||||
| Job state, retries, cancellation | Worker/job service | Durable and resumable |
|
||||
| Draft validation | Utils validation + API service | Zod schema validation before persistence |
|
||||
| Publishing | Publish service | Creates sections, lessons, lesson language content, exercises |
|
||||
| Learner events | Tracking endpoint/service | Idempotent events and progress aggregation |
|
||||
| Completion evaluation | Completion service | Central rule evaluator, not UI-driven |
|
||||
| Reporting/export | Reporting service | Reads progress aggregates and submissions |
|
||||
|
||||
### AI Tasks
|
||||
|
||||
The AI should perform bounded transformations only after source extraction:
|
||||
|
||||
| AI Task | Input | Output |
|
||||
| --- | --- | --- |
|
||||
| Structure inference | Source segment outline and selected segment text | Proposed lessons, optional sections, learning objectives |
|
||||
| Lesson drafting | Segment text for one planned lesson | Lesson body content, summary, key concepts |
|
||||
| Quiz generation | Segment text and learning objectives | Auto-gradable questions, options, correct answers, explanations |
|
||||
| Activity quiz generation | One source/activity segment set | Key subject matter, quiz, optional reflection prompt |
|
||||
| Coverage mapping assist | Generated item + source segment IDs | Segment references and confidence |
|
||||
| Regeneration | Existing draft item + source segments + teacher instruction | Replacement draft item |
|
||||
|
||||
The AI must not:
|
||||
|
||||
- publish directly
|
||||
- change permissions
|
||||
- create records without service validation
|
||||
- infer facts outside the provided source unless explicitly configured
|
||||
- generate questions without source segment references
|
||||
- fetch URLs itself
|
||||
- grade learner submissions in v1 unless the question type already has a supported grading path
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Teacher["Teacher UI"]
|
||||
API["Study Source API"]
|
||||
Worker["Source Worker"]
|
||||
Extractor["Extraction Service"]
|
||||
Segmenter["Segmentation Service"]
|
||||
AI["AI Generation Service"]
|
||||
Draft["Generation Draft"]
|
||||
Publish["Publish Service"]
|
||||
Course["Course / Lesson / Exercise Tables"]
|
||||
Learner["Learner UI"]
|
||||
Tracking["Tracking Service"]
|
||||
Reports["Reports"]
|
||||
|
||||
Teacher --> API
|
||||
API --> Worker
|
||||
Worker --> Extractor
|
||||
Extractor --> Segmenter
|
||||
Segmenter --> AI
|
||||
AI --> Draft
|
||||
Teacher --> Draft
|
||||
Draft --> Publish
|
||||
Publish --> Course
|
||||
Learner --> Tracking
|
||||
Tracking --> Reports
|
||||
Course --> Learner
|
||||
```
|
||||
|
||||
## Pipeline Overview
|
||||
|
||||
### Course Type Pipeline
|
||||
|
||||
```
|
||||
create source
|
||||
-> extract source
|
||||
-> normalize text
|
||||
-> segment source
|
||||
-> generate course outline draft
|
||||
-> teacher reviews outline
|
||||
-> generate lesson and quiz drafts per lesson
|
||||
-> teacher approves
|
||||
-> publish into course/sections/lessons/exercises
|
||||
-> learner completes course
|
||||
-> reports show progress, score, and source coverage
|
||||
```
|
||||
|
||||
### Activity Type Pipeline
|
||||
|
||||
```
|
||||
create source inside existing course
|
||||
-> extract source
|
||||
-> normalize text
|
||||
-> segment source
|
||||
-> generate source-study activity draft
|
||||
-> teacher reviews quiz and requirements
|
||||
-> publish as lesson variant + generated exercise
|
||||
-> learner studies source and submits quiz
|
||||
-> completion service evaluates activity completion
|
||||
```
|
||||
|
||||
## Source Intake and Extraction
|
||||
|
||||
### Intake Sources
|
||||
|
||||
#### File Upload
|
||||
|
||||
Supported v1:
|
||||
|
||||
- PDF
|
||||
- DOCX
|
||||
- PPTX
|
||||
- TXT
|
||||
- Markdown
|
||||
|
||||
Implementation:
|
||||
|
||||
1. API receives file metadata and creates a `study_source` record with status `draft`.
|
||||
2. File is uploaded to object storage and registered as an `asset`.
|
||||
3. API creates a `study_source_job` with type `extract_source`.
|
||||
4. Worker claims job and extracts text.
|
||||
5. Worker updates source status to `ready` or `failed`.
|
||||
|
||||
Use the current AI chat document extraction as a reference, but do not reuse its temporary conversation assumptions. The existing service stores extracted text against `ai_chat_document`; Study from Source needs durable source records and source segments.
|
||||
|
||||
#### Webpage URL
|
||||
|
||||
Supported v1:
|
||||
|
||||
- one public URL per source
|
||||
- server-side fetch
|
||||
- readable article/body extraction
|
||||
- stored snapshot
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Validate URL scheme is `https` or `http`.
|
||||
2. Block localhost, private IP ranges, link-local ranges, metadata service IPs, and internal hostnames.
|
||||
3. Resolve DNS before fetch and validate resolved IPs.
|
||||
4. Enforce redirect limit, response size limit, and timeout.
|
||||
5. Reject unsupported content types unless the response is parseable HTML/text.
|
||||
6. Store:
|
||||
- original URL
|
||||
- final URL
|
||||
- HTTP status
|
||||
- content type
|
||||
- fetch timestamp
|
||||
- extracted readable text
|
||||
- page title
|
||||
- canonical URL if present
|
||||
|
||||
The learner should see the stored snapshot/readable text by default, with the original URL shown as a citation/link.
|
||||
|
||||
#### Pasted Text
|
||||
|
||||
Supported v1:
|
||||
|
||||
- plain text
|
||||
- Markdown
|
||||
|
||||
Implementation:
|
||||
|
||||
1. API stores text directly in `study_source.rawText`.
|
||||
2. Worker normalizes and segments it like other source types.
|
||||
3. No external asset is required.
|
||||
|
||||
### Extraction Libraries
|
||||
|
||||
Candidate extraction approach:
|
||||
|
||||
| Source | Extractor |
|
||||
| --- | --- |
|
||||
| PDF | Existing `pdf-parse` initially; consider page-aware extractor if page locators are required |
|
||||
| DOCX | Existing `mammoth` |
|
||||
| PPTX | Existing ZIP/XML extraction, later replace with a stronger parser if needed |
|
||||
| HTML | `readability`-style extraction using DOM parsing |
|
||||
| TXT/Markdown | Direct decode and normalization |
|
||||
|
||||
### Extraction Output Contract
|
||||
|
||||
Extraction should produce a structured intermediate result:
|
||||
|
||||
```ts
|
||||
type ExtractedSource = {
|
||||
title: string | null;
|
||||
rawText: string;
|
||||
normalizedText: string;
|
||||
wordCount: number;
|
||||
pageCount?: number | null;
|
||||
language?: string | null;
|
||||
locators: Array<{
|
||||
kind: 'page' | 'heading' | 'slide' | 'paragraph' | 'url_fragment';
|
||||
label: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
warnings: Array<{
|
||||
code: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
}>;
|
||||
};
|
||||
```
|
||||
|
||||
The AI should consume the normalized text and segment records, not the uploaded file.
|
||||
|
||||
## Normalization
|
||||
|
||||
Normalization should be deterministic and conservative.
|
||||
|
||||
Steps:
|
||||
|
||||
1. decode text as UTF-8 where possible
|
||||
2. normalize line endings
|
||||
3. collapse repeated whitespace
|
||||
4. preserve headings, page breaks, slide breaks, and URL headings
|
||||
5. remove obvious webpage boilerplate such as nav/footer/cookie banners where the parser identifies them
|
||||
6. detect very low text density and mark extraction as weak
|
||||
7. compute word count and character count
|
||||
8. detect likely language
|
||||
|
||||
Do not rewrite content during normalization. Rewriting belongs to AI generation, not extraction.
|
||||
|
||||
## Segmentation
|
||||
|
||||
Segmentation is the contract between source extraction and AI generation.
|
||||
|
||||
### Deterministic Segmentation First
|
||||
|
||||
Use deterministic rules before AI:
|
||||
|
||||
- PDF: pages, detected headings, paragraph blocks
|
||||
- DOCX: heading styles if available, then paragraphs
|
||||
- PPTX: slide boundaries, slide titles
|
||||
- HTML: h1/h2/h3 sections, article paragraphs
|
||||
- Markdown: heading hierarchy
|
||||
- TXT: heading-like lines and paragraph grouping
|
||||
|
||||
### AI-Assisted Segmentation
|
||||
|
||||
Use AI-assisted segmentation only when deterministic segmentation is weak:
|
||||
|
||||
- no headings
|
||||
- long flat text
|
||||
- inconsistent formatting
|
||||
- extracted text has unclear boundaries
|
||||
|
||||
AI-assisted segmentation should receive chunked text and return only segment boundaries and titles, not rewritten lesson content.
|
||||
|
||||
### Segment Size Rules
|
||||
|
||||
Default target:
|
||||
|
||||
- minimum useful segment: 150 words
|
||||
- target segment: 500-1,200 words
|
||||
- maximum segment: 2,000 words before further splitting
|
||||
|
||||
Very short segments should be merged with neighbors unless they are headings, definitions, or policy clauses that need to remain separate.
|
||||
|
||||
### Segment Output
|
||||
|
||||
Each `study_source_segment` should include:
|
||||
|
||||
- stable order
|
||||
- title where available
|
||||
- kind
|
||||
- text
|
||||
- parent segment if hierarchical
|
||||
- source locator
|
||||
- extraction confidence or warnings
|
||||
|
||||
## AI Generation Responsibilities
|
||||
|
||||
### Course Outline Generation
|
||||
|
||||
Input:
|
||||
|
||||
- source metadata
|
||||
- segment list with IDs, titles, sizes, and short previews
|
||||
- teacher instructions
|
||||
- generation settings
|
||||
|
||||
Output:
|
||||
|
||||
- title
|
||||
- description
|
||||
- `usesSections`
|
||||
- optional sections
|
||||
- lessons
|
||||
- lesson-to-segment mapping
|
||||
- learning objectives
|
||||
- recommended quiz placement
|
||||
- warnings
|
||||
|
||||
Rules:
|
||||
|
||||
- for small sources, generate a flat lesson list
|
||||
- for large/naturally structured sources, generate sections
|
||||
- do not create a lesson without source segment IDs
|
||||
- do not include ungrounded content
|
||||
- include warnings for uncovered or low-confidence segments
|
||||
|
||||
### Lesson Draft Generation
|
||||
|
||||
Input:
|
||||
|
||||
- approved lesson plan item
|
||||
- full text of mapped source segments
|
||||
- course style settings
|
||||
- teacher instructions
|
||||
|
||||
Output:
|
||||
|
||||
- lesson body HTML
|
||||
- summary
|
||||
- key concepts
|
||||
- source segment references
|
||||
- warnings
|
||||
|
||||
Rules:
|
||||
|
||||
- lesson body starts at `h3` or lower to match existing lesson content rules
|
||||
- keep source-grounded; do not add external facts by default
|
||||
- explain and teach, but do not invent new policy requirements
|
||||
- include source coverage metadata
|
||||
|
||||
### Quiz Generation
|
||||
|
||||
Input:
|
||||
|
||||
- source segment text
|
||||
- learning objectives
|
||||
- desired question count
|
||||
- allowed question types
|
||||
- passing score
|
||||
|
||||
Output:
|
||||
|
||||
- questions
|
||||
- options
|
||||
- correct answers
|
||||
- answer explanations
|
||||
- source segment references
|
||||
- difficulty
|
||||
|
||||
Rules:
|
||||
|
||||
- v1 should prefer auto-gradable question types
|
||||
- every question must map to at least one source segment
|
||||
- avoid trick questions
|
||||
- avoid questions whose answer depends on ambiguous wording
|
||||
- avoid questions about insignificant details unless the teacher asks for strict recall
|
||||
- generated options must be mutually exclusive for single-answer questions
|
||||
- checkbox questions must have at least two correct options only when the source supports that clearly
|
||||
|
||||
### Activity Generation
|
||||
|
||||
Input:
|
||||
|
||||
- source segments for one activity
|
||||
- teacher settings
|
||||
|
||||
Output:
|
||||
|
||||
- activity title
|
||||
- source summary
|
||||
- key subject matter list
|
||||
- quiz
|
||||
- completion settings
|
||||
- source coverage
|
||||
|
||||
Rules:
|
||||
|
||||
- do not split activity into multiple lessons
|
||||
- focus quiz on key subject matter, not every paragraph
|
||||
- activity summary is optional learner support, not proof of study
|
||||
|
||||
## Data Model Additions
|
||||
|
||||
### Enums
|
||||
|
||||
Add:
|
||||
|
||||
- `COURSE_TYPE`: `STUDY_FROM_SOURCE`
|
||||
- `STUDY_SOURCE_TYPE`: `file`, `webpage`, `text`
|
||||
- `STUDY_SOURCE_STATUS`: `draft`, `extracting`, `ready`, `failed`, `archived`
|
||||
- `STUDY_SOURCE_JOB_TYPE`: `extract_source`, `segment_source`, `generate_course_outline`, `generate_activity`, `publish_draft`
|
||||
- `STUDY_SOURCE_JOB_STATUS`: `pending`, `running`, `completed`, `failed`, `canceled`
|
||||
- `STUDY_SOURCE_SEGMENT_KIND`: `chapter`, `section`, `paragraph`, `page`, `slide`, `inferred_topic`
|
||||
- `STUDY_SOURCE_DRAFT_KIND`: `course_outline`, `activity_quiz`, `lesson_regeneration`, `quiz_regeneration`
|
||||
- `STUDY_SOURCE_ACTIVITY_STATUS`: `draft`, `published`, `archived`
|
||||
|
||||
### Tables
|
||||
|
||||
Use the PRD data model plus add an explicit job table:
|
||||
|
||||
#### `study_source_job`
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | Job ID |
|
||||
| `organizationId` | uuid | Scope |
|
||||
| `sourceId` | uuid nullable | Source |
|
||||
| `draftId` | uuid nullable | Draft |
|
||||
| `type` | enum | Job type |
|
||||
| `status` | enum | Job status |
|
||||
| `attempt` | integer | Current attempt |
|
||||
| `maxAttempts` | integer | Retry cap |
|
||||
| `leasedBy` | text nullable | Worker lease |
|
||||
| `leaseExpiresAt` | timestamp nullable | Worker lease expiry |
|
||||
| `progressPercent` | integer | UI progress |
|
||||
| `stage` | text | Human-readable stage |
|
||||
| `checkpoint` | jsonb | Resume data |
|
||||
| `input` | jsonb | Job input |
|
||||
| `output` | jsonb | Job result |
|
||||
| `warnings` | jsonb | Non-fatal warnings |
|
||||
| `error` | jsonb | Terminal error |
|
||||
| `createdAt` / `updatedAt` | timestamp | Audit fields |
|
||||
|
||||
The job system should use DB-backed leasing with `FOR UPDATE SKIP LOCKED`, matching the pattern proposed in the course importer plan.
|
||||
|
||||
## API Boundaries
|
||||
|
||||
### Routes
|
||||
|
||||
Keep routes thin:
|
||||
|
||||
- validate input
|
||||
- enforce auth/middleware
|
||||
- call service
|
||||
- return one response shape
|
||||
- use `handleError`
|
||||
|
||||
### Services
|
||||
|
||||
Suggested service modules:
|
||||
|
||||
- `apps/api/src/services/study-source/source.ts`
|
||||
- `apps/api/src/services/study-source/extraction.ts`
|
||||
- `apps/api/src/services/study-source/segmentation.ts`
|
||||
- `apps/api/src/services/study-source/generation.ts`
|
||||
- `apps/api/src/services/study-source/publish.ts`
|
||||
- `apps/api/src/services/study-source/progress.ts`
|
||||
- `apps/api/src/services/study-source/reporting.ts`
|
||||
|
||||
### Queries
|
||||
|
||||
Suggested query module:
|
||||
|
||||
- `packages/db/src/queries/study-source/study-source.ts`
|
||||
|
||||
Queries stay pure and do not know HTTP concerns.
|
||||
|
||||
### Validation
|
||||
|
||||
Suggested validation module:
|
||||
|
||||
- `packages/utils/src/validation/study-source/study-source.ts`
|
||||
|
||||
Schemas should cover:
|
||||
|
||||
- source create
|
||||
- file finalize
|
||||
- URL create
|
||||
- pasted text create
|
||||
- generation settings
|
||||
- draft update
|
||||
- publish
|
||||
- activity config
|
||||
- learner event
|
||||
- report query
|
||||
|
||||
## Async Job Design
|
||||
|
||||
Extraction and AI generation should be async jobs.
|
||||
|
||||
Do not hold a request open while:
|
||||
|
||||
- fetching a webpage
|
||||
- parsing a large PDF
|
||||
- segmenting large text
|
||||
- generating a full course outline
|
||||
- generating all lesson content
|
||||
- publishing a large generated course
|
||||
|
||||
### Job Lifecycle
|
||||
|
||||
```
|
||||
pending -> running -> completed
|
||||
-> failed
|
||||
-> canceled
|
||||
```
|
||||
|
||||
### Retry Policy
|
||||
|
||||
- extraction failures caused by invalid input should not retry
|
||||
- network timeouts for webpage fetch can retry with backoff
|
||||
- AI rate limits can retry with backoff
|
||||
- schema validation failures from AI output should retry once with a repair prompt
|
||||
- publish failures should be resumable with idempotency keys
|
||||
|
||||
### Idempotency
|
||||
|
||||
Use idempotency keys for:
|
||||
|
||||
- source creation from URL
|
||||
- draft generation
|
||||
- publish
|
||||
- learner event ingestion
|
||||
|
||||
Publish must be safe to retry without duplicate sections, lessons, exercises, or questions.
|
||||
|
||||
## Publish Strategy
|
||||
|
||||
### Course Type Publish
|
||||
|
||||
Publishing a course draft should:
|
||||
|
||||
1. create or update course shell with type `STUDY_FROM_SOURCE`
|
||||
2. set `isContentGroupingEnabled` based on approved outline
|
||||
3. create sections if `usesSections` is true
|
||||
4. create lessons in order
|
||||
5. create lesson language content
|
||||
6. create exercises and questions
|
||||
7. create source coverage records
|
||||
8. mark draft `published`
|
||||
|
||||
### Activity Publish
|
||||
|
||||
Publishing an activity draft should:
|
||||
|
||||
1. create a lesson in the existing course
|
||||
2. create `study_source_activity`
|
||||
3. attach source metadata to the activity
|
||||
4. create generated exercise linked to the lesson
|
||||
5. create source coverage records for quiz questions
|
||||
6. mark activity/draft `published`
|
||||
|
||||
### Partial Publish Failure
|
||||
|
||||
Publish should store checkpoints after each created entity:
|
||||
|
||||
- course created
|
||||
- section IDs created
|
||||
- lesson IDs created
|
||||
- lesson content written
|
||||
- exercise IDs created
|
||||
- questions created
|
||||
- coverage written
|
||||
|
||||
On retry, the service should read the checkpoint and continue instead of creating duplicates.
|
||||
|
||||
## Learner Tracking
|
||||
|
||||
### Events
|
||||
|
||||
Events should be append-only where possible:
|
||||
|
||||
- `opened`
|
||||
- `segment_viewed`
|
||||
- `progress_updated`
|
||||
- `active_time_recorded`
|
||||
- `quiz_started`
|
||||
- `quiz_submitted`
|
||||
- `attestation_checked`
|
||||
- `completed`
|
||||
|
||||
### Event Validation
|
||||
|
||||
Reject or normalize:
|
||||
|
||||
- progress below previous progress unless explicitly resetting
|
||||
- active time heartbeats that are too frequent
|
||||
- active time while tab is hidden
|
||||
- events for unpublished activities
|
||||
- events from users not enrolled/authorized for the course
|
||||
|
||||
### Progress Aggregation
|
||||
|
||||
Progress should be aggregated server-side from events and quiz submissions.
|
||||
|
||||
Completion should be evaluated by a single service:
|
||||
|
||||
```ts
|
||||
type StudySourceCompletionInput = {
|
||||
requiredProgressPercent: number;
|
||||
minimumActiveTimeSeconds: number | null;
|
||||
passingScorePercent: number;
|
||||
requiresAttestation: boolean;
|
||||
progressPercent: number;
|
||||
activeTimeSeconds: number;
|
||||
quizScorePercent: number | null;
|
||||
attestedAt: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
The UI should never mark an activity complete by itself.
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### Course Creation
|
||||
|
||||
Add a Study from Source option to course creation.
|
||||
|
||||
Screens:
|
||||
|
||||
1. source input
|
||||
2. extraction status and preview
|
||||
3. generation settings
|
||||
4. outline review
|
||||
5. publish progress
|
||||
6. generated course preview
|
||||
|
||||
### Activity Creation
|
||||
|
||||
Add Study from Source to the content create modal.
|
||||
|
||||
Screens:
|
||||
|
||||
1. source input
|
||||
2. extraction preview
|
||||
3. quiz settings
|
||||
4. generated quiz review
|
||||
5. publish
|
||||
|
||||
### Learner Reader
|
||||
|
||||
The reader should support:
|
||||
|
||||
- normalized text display
|
||||
- source metadata
|
||||
- original source link for webpages
|
||||
- progress tracking
|
||||
- generated quiz
|
||||
- attestation if enabled
|
||||
|
||||
Do not render raw untrusted HTML from webpages without sanitization.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### File Edge Cases
|
||||
|
||||
| Edge Case | Expected Behavior |
|
||||
| --- | --- |
|
||||
| Unsupported MIME type | Reject with clear error |
|
||||
| MIME type spoofing | Verify extension and content signature where practical |
|
||||
| File exceeds limit | Reject before extraction |
|
||||
| Empty file | Mark extraction failed |
|
||||
| PDF has no text layer | Mark extraction failed with scanned-PDF warning |
|
||||
| Password-protected PDF | Reject with unsupported/protected file error |
|
||||
| Corrupt DOCX/PPTX | Mark extraction failed and keep source in failed state |
|
||||
| Huge extracted text | Truncate or require teacher to split source; show warning |
|
||||
| Tables/formulas extracted poorly | Store warning; teacher can review extracted preview |
|
||||
| Images contain important text | Mark as not extracted in v1; OCR out of scope |
|
||||
| Duplicate upload | Allow, but optionally detect hash and warn |
|
||||
|
||||
### Webpage Edge Cases
|
||||
|
||||
| Edge Case | Expected Behavior |
|
||||
| --- | --- |
|
||||
| URL is localhost/private IP | Reject to prevent SSRF |
|
||||
| URL redirects to private IP | Reject after redirect validation |
|
||||
| URL returns non-HTML binary | Reject unless supported document type |
|
||||
| URL blocks fetch | Mark failed with fetch error |
|
||||
| URL times out | Retry with backoff, then fail |
|
||||
| Page is mostly nav/ads | Extract best readable content; warn if text density is low |
|
||||
| Page requires login | Fail with "content not accessible" |
|
||||
| Paywall or cookie wall | Extract available text; warn that content may be incomplete |
|
||||
| Page content changes later | Existing course/activity keeps stored snapshot |
|
||||
| Canonical URL differs | Store both original and canonical/final URL |
|
||||
| Robots/meta restrictions | Store warning and respect product policy decided before launch |
|
||||
|
||||
### Text Edge Cases
|
||||
|
||||
| Edge Case | Expected Behavior |
|
||||
| --- | --- |
|
||||
| Text is too short for quiz | Allow source, block quiz generation or generate fewer questions |
|
||||
| Text is too long | Enforce character limit and show warning |
|
||||
| Text has no structure | Use AI-assisted segmentation |
|
||||
| Text is not in supported locale | Detect language and warn if generation quality may be lower |
|
||||
|
||||
### AI Edge Cases
|
||||
|
||||
| Edge Case | Expected Behavior |
|
||||
| --- | --- |
|
||||
| AI returns invalid JSON | Retry once with repair prompt; then fail draft generation |
|
||||
| AI omits source references | Reject output and retry |
|
||||
| AI creates unsupported question type | Reject output and retry or downgrade to supported type |
|
||||
| AI invents facts outside source | Hard to detect automatically; require teacher review and source references |
|
||||
| AI makes every lesson too long | Enforce max output lengths per lesson |
|
||||
| AI creates too many lessons | Enforce generation settings and max lesson count |
|
||||
| AI creates trivial quiz questions | Flag via quality checks where possible; teacher can regenerate |
|
||||
| AI rate limit | Retry job with backoff |
|
||||
| AI token limit | Chunk by segments and generate per lesson/activity |
|
||||
|
||||
### Publish Edge Cases
|
||||
|
||||
| Edge Case | Expected Behavior |
|
||||
| --- | --- |
|
||||
| Teacher edits draft while publish runs | Lock draft during publish |
|
||||
| Publish interrupted after sections created | Resume from checkpoint |
|
||||
| Existing course has content | Activity publish appends; course-type publish creates new course unless explicit merge is supported |
|
||||
| Duplicate publish click | Idempotency prevents duplicates |
|
||||
| Question validation fails | Fail publish before partial question creation where possible |
|
||||
| Lesson content validation fails | Keep draft unpublished with actionable error |
|
||||
|
||||
### Learner Tracking Edge Cases
|
||||
|
||||
| Edge Case | Expected Behavior |
|
||||
| --- | --- |
|
||||
| Learner opens source then immediately submits quiz | Completion depends on configured progress/score rules |
|
||||
| Learner switches tabs | Active time heartbeat pauses |
|
||||
| Learner loses network | Events queue client-side and replay with idempotency keys |
|
||||
| Learner retakes quiz | Progress uses best or latest score based on activity settings |
|
||||
| Teacher changes passing score after attempts | Recompute completion according to product policy; default should apply new rules only to future attempts |
|
||||
| Source/activity unpublished | Reject learner events |
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- URL validation and SSRF blocking
|
||||
- file type validation
|
||||
- text normalization
|
||||
- deterministic segmentation
|
||||
- source completion evaluator
|
||||
- generated draft schema validation
|
||||
- publish mapping functions
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- create source from pasted text
|
||||
- upload file and create source record
|
||||
- webpage fetch success/failure
|
||||
- extraction job state transitions
|
||||
- generation draft persistence
|
||||
- publish draft to course
|
||||
- publish activity into existing course
|
||||
- learner event ingestion and progress aggregation
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- teacher creates Study from Source course from pasted text and publishes
|
||||
- teacher adds source-study activity to existing course
|
||||
- learner studies activity, passes quiz, completion appears in report
|
||||
- failed extraction shows actionable UI state
|
||||
- retry publish does not duplicate content
|
||||
|
||||
## Delivery Order
|
||||
|
||||
1. Data model and validation schemas
|
||||
2. Source creation and pasted text ingestion
|
||||
3. Async job foundation
|
||||
4. File extraction using existing extraction libraries
|
||||
5. Webpage fetch and readable extraction with SSRF protections
|
||||
6. Segmentation service
|
||||
7. Course outline draft generation
|
||||
8. Activity quiz draft generation
|
||||
9. Teacher review UI
|
||||
10. Publish service for activity path
|
||||
11. Publish service for course type path
|
||||
12. Learner reader and tracking
|
||||
13. Reports and CSV export
|
||||
|
||||
Build the activity path before the full course path if delivery needs to be staged. It exercises the same source pipeline with lower publish complexity.
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
# Video Recording Question Type PRD
|
||||
|
||||
## Status
|
||||
|
||||
- Done (shipped)
|
||||
|
||||
## Date
|
||||
|
||||
- April 29, 2026
|
||||
|
||||
## Purpose
|
||||
|
||||
Add a new exercise question type that lets students record a short video response directly in the browser. The student clicks "Start recording", sees a circular selfie-style camera frame with a timer UI, records within the teacher's configured duration limit, can retake the recording multiple times, and submits the final recording as the answer.
|
||||
|
||||
This is not a generic video upload question. Students must record inside ClassroomIO instead of choosing an existing video file from their device.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Current exercise question types support text, objective answers, links, and file uploads, but they do not support authentic spoken or visual responses captured at answer time.
|
||||
|
||||
Teachers need video responses for use cases such as:
|
||||
|
||||
1. Oral language assessments.
|
||||
2. Reflection prompts.
|
||||
3. Presentation practice.
|
||||
4. Speaking assignments.
|
||||
5. Student identity or presence checks for low-stakes activities.
|
||||
6. Short self-introduction or portfolio responses.
|
||||
|
||||
Using generic file upload for this workflow is weaker because:
|
||||
|
||||
1. Students can upload pre-recorded or edited videos.
|
||||
2. The teacher cannot control recording duration in the answer UI.
|
||||
3. The experience is not optimized for quick mobile selfie responses.
|
||||
4. Retake behavior is unclear.
|
||||
5. The system cannot reliably guide capture, upload, and answer persistence as one flow.
|
||||
|
||||
## Product Thesis
|
||||
|
||||
A first-class video recording question type gives teachers richer student responses while keeping the experience constrained, simple, and assessment-friendly. The product should feel like recording a short circular selfie video message, not managing a media file.
|
||||
|
||||
## Current-State Audit
|
||||
|
||||
| Area | Current State | Gap |
|
||||
| --- | --- | --- |
|
||||
| Question type registry | `@cio/question-types` supports 12 question types | No `VIDEO_RECORDING` type |
|
||||
| File upload question | Supports uploaded answer files | Allows choosing files, which is explicitly not this workflow |
|
||||
| Media storage | Existing video presign and Cloudflare-compatible object storage paths exist | Needs answer-specific recording upload contract and metadata |
|
||||
| Question renderers | `@cio/ui/custom/exercise-question` owns edit/take/preview/submission renderers | Needs recording-specific edit, take, preview, and submission renderers |
|
||||
| Exercise submission | Answers serialize through shared answer codecs | Needs a video recording answer payload |
|
||||
| Grading | Manual types route to manual grading | Video response must be manual grading required |
|
||||
| Student UI | Existing take mode is form-like | Needs camera permission, timer, circular preview, stop, replay, retake, upload states |
|
||||
|
||||
## Data Sources Checked
|
||||
|
||||
- `packages/question-types/README.md`
|
||||
- `packages/question-types/ADDING_A_QUESTION_TYPE.md`
|
||||
- `packages/question-types/src/question-type-registry.ts`
|
||||
- `packages/question-types/src/answer-data.ts`
|
||||
- `packages/question-types/src/answer-codecs.ts`
|
||||
- `packages/ui/src/custom/exercise-question/renderer-contract.ts`
|
||||
- `packages/ui/src/custom/exercise-question/renderers/file-upload/`
|
||||
- `apps/api/src/routes/course/presign.ts`
|
||||
- `apps/api/src/utils/s3.ts`
|
||||
- `prd/exercise-question-types [DONE]/README.md`
|
||||
- `prd/media-manager [DONE]/README.md`
|
||||
- `prd/media-manager [DONE]/decoupling.md`
|
||||
|
||||
## Primary Users
|
||||
|
||||
- Teachers creating exercises that require short video responses.
|
||||
- Students recording responses on desktop or mobile browsers.
|
||||
- Instructors, tutors, and admins reviewing submissions.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add a `VIDEO_RECORDING` exercise question type.
|
||||
2. Let teachers configure a max recording duration per question.
|
||||
3. Let students record directly from camera and microphone inside the answer UI.
|
||||
4. Present a circular selfie-style video frame during recording and preview.
|
||||
5. Show a clear timer and enforce the configured max duration.
|
||||
6. Let students retake the recording multiple times before final submission.
|
||||
7. Upload the final selected recording to Cloudflare-backed storage.
|
||||
8. Store the uploaded recording's `assetId`, `storageKey`, and metadata as the student's answer.
|
||||
9. Make the answer reviewable by teachers in submission review.
|
||||
10. Keep this separate from the existing file upload question type.
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Uploading an existing video file from the device.
|
||||
- In-browser video editing, trimming, filters, captions, or effects.
|
||||
- Multi-clip recordings.
|
||||
- Screen recording.
|
||||
- Live teacher feedback during recording.
|
||||
- Automatic speech transcription.
|
||||
- AI scoring or content moderation.
|
||||
- Proctoring-grade identity verification.
|
||||
- Offline recording with later background sync.
|
||||
- Mandatory one-take-only recording restrictions.
|
||||
|
||||
## Confirmed Product Decisions
|
||||
|
||||
1. The question type is recording-only, not upload-based.
|
||||
2. Teachers configure the max duration per question.
|
||||
3. Students can retake multiple times before submitting.
|
||||
4. Only the final selected take is submitted as the answer.
|
||||
5. Recording UI uses a circular video frame, similar to short selfie video messages.
|
||||
6. The recording is uploaded to Cloudflare-backed storage after the student accepts the take.
|
||||
7. The answer stores `storageKey`, `assetId`, and supporting metadata. Playback URLs are generated on demand.
|
||||
8. The question requires manual grading.
|
||||
9. Upload starts immediately after the student accepts a take, not when the full exercise is submitted.
|
||||
10. Cleanup for superseded, abandoned, and expired recordings is in scope for v1.
|
||||
|
||||
## Open Product Decisions
|
||||
|
||||
1. Should v1 enforce a platform-wide maximum duration, such as 30, 60, 120, or 180 seconds?
|
||||
2. Should students be allowed to delete a saved final recording before submitting the exercise?
|
||||
3. Should failed uploads block the full exercise submission or only mark that question incomplete?
|
||||
4. Should teachers see retake count metadata, or should retakes remain private to the student?
|
||||
5. What retention window should be used before deleting superseded recording assets?
|
||||
|
||||
## User Stories
|
||||
|
||||
### Teacher Authoring
|
||||
|
||||
- As a teacher, I can choose "Video recording" from the question type picker.
|
||||
- As a teacher, I can write the prompt and instructions for the recording.
|
||||
- As a teacher, I can set the maximum recording duration.
|
||||
- As a teacher, I can preview the student recording experience.
|
||||
- As a teacher, I can see that video recording questions require manual grading.
|
||||
|
||||
### Student Recording
|
||||
|
||||
- As a student, I can grant camera and microphone permission.
|
||||
- As a student, I can see myself in a circular video frame before recording.
|
||||
- As a student, I can click "Start recording" to begin.
|
||||
- As a student, I can see elapsed time and remaining time while recording.
|
||||
- As a student, recording stops automatically at the max duration.
|
||||
- As a student, I can stop early.
|
||||
- As a student, I can replay my recording before accepting it.
|
||||
- As a student, I can retake the video multiple times.
|
||||
- As a student, I can submit only the take I accepted.
|
||||
- As a student, I see upload progress and errors clearly.
|
||||
|
||||
### Teacher Review
|
||||
|
||||
- As a teacher, I can play the submitted video answer in the submission review screen.
|
||||
- As a teacher, I can see duration, upload status, submitted time, and file metadata.
|
||||
- As a teacher, I can grade the response manually and leave feedback.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-1: Question Type Registration
|
||||
|
||||
Add a new question type:
|
||||
|
||||
- Key: `VIDEO_RECORDING`
|
||||
- Label: `Video recording`
|
||||
- Auto-gradable: `false`
|
||||
- Supports partial credit: `false`
|
||||
- Manual grading required: `true`
|
||||
|
||||
The type must be added to:
|
||||
|
||||
- `@cio/question-types` keys and registry.
|
||||
- DB `question_type` seed/reference data.
|
||||
- Question type picker.
|
||||
- Renderer contract.
|
||||
- Dashboard translation labels.
|
||||
- Submission review UI.
|
||||
|
||||
### FR-2: Authoring Settings
|
||||
|
||||
Teachers can configure:
|
||||
|
||||
- Prompt.
|
||||
- Optional instructions.
|
||||
- Points.
|
||||
- Required/optional.
|
||||
- Max recording duration in seconds.
|
||||
|
||||
Default settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"maxDurationSeconds": 60,
|
||||
"allowRetakes": true
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `maxDurationSeconds` is required.
|
||||
- `maxDurationSeconds` must be greater than `0`.
|
||||
- `maxDurationSeconds` must not exceed the platform maximum.
|
||||
- `allowRetakes` is always `true` in v1 and can be omitted from the UI if the product does not need a toggle.
|
||||
|
||||
### FR-3: Student Recording UI
|
||||
|
||||
The take renderer must include:
|
||||
|
||||
- Camera permission state.
|
||||
- Microphone permission state.
|
||||
- Circular live video preview.
|
||||
- Start recording action.
|
||||
- Stop recording action.
|
||||
- Timer UI during recording.
|
||||
- Automatic stop at max duration.
|
||||
- Playback preview after recording.
|
||||
- Accept/use recording action.
|
||||
- Retake action.
|
||||
- Upload progress state.
|
||||
- Upload failure state with retry.
|
||||
|
||||
Recording states:
|
||||
|
||||
| State | Description |
|
||||
| --- | --- |
|
||||
| `idle` | No permission requested or no stream active |
|
||||
| `permission_denied` | Browser denied camera or microphone |
|
||||
| `ready` | Camera preview is active and user can start |
|
||||
| `recording` | MediaRecorder is capturing video/audio |
|
||||
| `recorded` | A local blob exists for preview |
|
||||
| `uploading` | Accepted recording is uploading |
|
||||
| `uploaded` | Cloudflare upload completed and answer is set |
|
||||
| `failed` | Recording or upload failed |
|
||||
|
||||
### FR-4: Timer Behavior
|
||||
|
||||
The timer must show:
|
||||
|
||||
- Elapsed time.
|
||||
- Remaining time.
|
||||
- Max duration.
|
||||
|
||||
Rules:
|
||||
|
||||
- Timer starts when recording begins.
|
||||
- Timer stops when the student clicks stop.
|
||||
- Recording auto-stops when elapsed time reaches `maxDurationSeconds`.
|
||||
- UI should warn visually in the final 10 seconds for recordings longer than 15 seconds.
|
||||
- If the recording is shorter than 1 second, the UI should ask the student to record again.
|
||||
|
||||
### FR-5: Retake Behavior
|
||||
|
||||
Students can retake the video multiple times before submitting.
|
||||
|
||||
Rules:
|
||||
|
||||
- Retake discards the current local recording.
|
||||
- Retake does not synchronously delete already-uploaded Cloudflare objects.
|
||||
- The answer always points to the latest accepted and successfully uploaded recording.
|
||||
- If the student records again after an upload, the UI must make clear that the new take replaces the previous answer after upload succeeds.
|
||||
- v1 does not limit the number of retakes.
|
||||
- Superseded uploaded takes must be marked for cleanup once a newer accepted take is successfully uploaded.
|
||||
|
||||
### FR-6: Upload Behavior
|
||||
|
||||
After the student accepts a recording:
|
||||
|
||||
1. Convert the captured recording blob to a supported video MIME type.
|
||||
2. Request a video-recording upload URL and pending asset from the API.
|
||||
3. Upload the blob to Cloudflare-backed object storage.
|
||||
4. Complete the upload with duration, size, and MIME metadata.
|
||||
5. Store the resulting answer payload with `assetId` and `storageKey`.
|
||||
6. Show the uploaded state in the UI.
|
||||
|
||||
Recommended v1 upload timing:
|
||||
|
||||
- Upload immediately after the student accepts a take.
|
||||
- Do not wait until the full exercise submit action, because large video uploads are more failure-prone than text answers.
|
||||
- The exercise answer should not be considered complete until the accepted take has uploaded and the asset completion step has returned.
|
||||
|
||||
Upload constraints:
|
||||
|
||||
- Upload endpoint must require authentication.
|
||||
- Upload endpoint must validate course/exercise/question access.
|
||||
- Upload endpoint must enforce max duration metadata from the question settings where possible.
|
||||
- Upload endpoint must restrict MIME types to browser-recordable video types.
|
||||
- Upload endpoint must produce a storage key in a recording-specific namespace.
|
||||
- Upload completion must create or finalize a media asset and return `assetId`.
|
||||
- The API must not return or persist a long-lived playback URL as the answer.
|
||||
|
||||
### FR-7: Answer Payload
|
||||
|
||||
Store the final accepted recording as structured answer data.
|
||||
|
||||
Proposed answer data:
|
||||
|
||||
```typescript
|
||||
export type VideoRecordingAnswerData = {
|
||||
type: 'VIDEO_RECORDING';
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
durationSeconds: number;
|
||||
recordedAt: string;
|
||||
uploadedAt: string;
|
||||
provider: 'cloudflare';
|
||||
};
|
||||
|
||||
export type VideoRecordingAnswerResponseData = VideoRecordingAnswerData & {
|
||||
playbackUrl?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Storage rules:
|
||||
|
||||
- `assetId` and `storageKey` are required and are the durable answer reference.
|
||||
- `playbackUrl` is transient and must only be attached to API responses after access checks.
|
||||
- Do not persist `playbackUrl` as the canonical answer.
|
||||
- If thumbnail generation is implemented, store `thumbnailUrl` on the asset metadata, not as the primary answer reference.
|
||||
|
||||
### FR-8: Teacher Submission Review
|
||||
|
||||
Submission review must render:
|
||||
|
||||
- Video player.
|
||||
- Playback URL generated on demand from `assetId` or `storageKey`.
|
||||
- Duration.
|
||||
- File size.
|
||||
- Submitted timestamp.
|
||||
- Manual grading controls.
|
||||
- Feedback controls.
|
||||
|
||||
If the video cannot be loaded:
|
||||
|
||||
- Show a clear unavailable state.
|
||||
- Offer retry by requesting a fresh playback URL.
|
||||
- Preserve grading and feedback controls.
|
||||
|
||||
### FR-9: Validation and Grading
|
||||
|
||||
Submission validation:
|
||||
|
||||
- Required video recording questions are incomplete until an uploaded answer payload exists.
|
||||
- Optional video recording questions can be skipped.
|
||||
- Payload must include `assetId`, `storageKey`, `mimeType`, `size`, and `durationSeconds`.
|
||||
- `durationSeconds` must be less than or equal to the configured `maxDurationSeconds` plus a small server tolerance.
|
||||
|
||||
Grading:
|
||||
|
||||
- Video recording questions are manual grading required.
|
||||
- Exercises containing video recording questions cannot be fully auto-graded.
|
||||
- Teachers can assign points and feedback in the existing manual grading flow.
|
||||
|
||||
### FR-10: Browser Compatibility
|
||||
|
||||
Use the browser `MediaRecorder` and `getUserMedia` APIs.
|
||||
|
||||
Supported baseline:
|
||||
|
||||
- Latest Chrome.
|
||||
- Latest Edge.
|
||||
- Latest Firefox.
|
||||
- Latest Safari on macOS and iOS, subject to MediaRecorder support validation.
|
||||
|
||||
If recording is unsupported:
|
||||
|
||||
- Show a browser-not-supported message.
|
||||
- Do not fall back to file upload in v1.
|
||||
|
||||
## UX Requirements
|
||||
|
||||
### Student Take Mode
|
||||
|
||||
The video recording area should feel lightweight and mobile-first:
|
||||
|
||||
- Circular camera frame.
|
||||
- Centered face/selfie composition.
|
||||
- Clear start and stop controls.
|
||||
- Timer visible near the frame.
|
||||
- Retake and use-recording actions after playback.
|
||||
- Upload state shown inline, not as a separate modal.
|
||||
|
||||
Recommended layout:
|
||||
|
||||
1. Prompt and instructions.
|
||||
2. Circular preview frame.
|
||||
3. Timer row.
|
||||
4. Primary action button.
|
||||
5. Secondary actions for retake/retry after recording.
|
||||
6. Upload/progress/error message.
|
||||
|
||||
### Teacher Edit Mode
|
||||
|
||||
Teacher controls should be minimal:
|
||||
|
||||
- Max duration input.
|
||||
- Helper text explaining that students must record in browser and can retake before submitting.
|
||||
- Manual grading badge or hint.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- All controls must be keyboard accessible.
|
||||
- Timer text must be readable by screen readers.
|
||||
- Recording state changes must be announced.
|
||||
- Buttons must not rely only on color.
|
||||
- Camera permission errors must include text instructions.
|
||||
- Video playback must expose native controls or equivalent accessible controls.
|
||||
|
||||
## Technical Design
|
||||
|
||||
### 1. Shared Question Type Package
|
||||
|
||||
Add `VIDEO_RECORDING` to `@cio/question-types`:
|
||||
|
||||
- `question-type-keys.ts`
|
||||
- `question-type-registry.ts`
|
||||
- `answer-data.ts`
|
||||
- `answer-codecs.ts`
|
||||
- `question-answer-payload.ts`
|
||||
- `exercise-types.ts` label keys
|
||||
- tests
|
||||
|
||||
Expected registry id:
|
||||
|
||||
- Use the next unused id after `LINK` (`13`) unless production data has already claimed it.
|
||||
|
||||
### 2. UI Renderer Package
|
||||
|
||||
Add renderers under:
|
||||
|
||||
```text
|
||||
packages/ui/src/custom/exercise-question/renderers/video-recording/
|
||||
edit.svelte
|
||||
take.svelte
|
||||
preview.svelte
|
||||
submission.svelte
|
||||
```
|
||||
|
||||
Register in:
|
||||
|
||||
- `packages/ui/src/custom/exercise-question/renderer-contract.ts`
|
||||
- `packages/ui/src/custom/exercise-question/index.ts`
|
||||
|
||||
The `take.svelte` renderer owns recording UI state but should delegate upload to the injected file/video upload callback or a dashboard adapter. `@cio/ui` should not directly know API routes.
|
||||
|
||||
### 3. Dashboard Integration
|
||||
|
||||
Update:
|
||||
|
||||
- Question type picker metadata.
|
||||
- Exercise labels and translations.
|
||||
- Student exercise take flow.
|
||||
- Submission review flow.
|
||||
- Answer serialization usage if dashboard-specific adapters are needed.
|
||||
|
||||
All user-facing copy must live in dashboard translations.
|
||||
|
||||
### 4. API Upload Contract
|
||||
|
||||
Add recording-specific upload routes instead of reusing generic lesson video upload directly.
|
||||
|
||||
Proposed upload initialization endpoint:
|
||||
|
||||
```text
|
||||
POST /course/:courseId/exercise/:exerciseId/question/:questionId/video-recording/upload/init
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"assetId": "asset_uuid",
|
||||
"uploadUrl": "https://...",
|
||||
"fileKey": "exercise-recordings/org-id/course-id/exercise-id/question-id/uuid.webm",
|
||||
"expiresAt": "2026-04-29T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Proposed upload completion endpoint:
|
||||
|
||||
```text
|
||||
POST /course/:courseId/exercise/:exerciseId/question/:questionId/video-recording/upload/complete
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"assetId": "asset_uuid",
|
||||
"storageKey": "exercise-recordings/org-id/course-id/exercise-id/question-id/uuid.webm",
|
||||
"fileName": "recording.webm",
|
||||
"mimeType": "video/webm",
|
||||
"size": 1048576,
|
||||
"durationSeconds": 58,
|
||||
"recordedAt": "2026-04-29T11:59:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"type": "VIDEO_RECORDING",
|
||||
"assetId": "asset_uuid",
|
||||
"storageKey": "exercise-recordings/org-id/course-id/exercise-id/question-id/uuid.webm",
|
||||
"fileName": "recording.webm",
|
||||
"mimeType": "video/webm",
|
||||
"size": 1048576,
|
||||
"durationSeconds": 58,
|
||||
"recordedAt": "2026-04-29T11:59:00.000Z",
|
||||
"uploadedAt": "2026-04-29T12:00:00.000Z",
|
||||
"provider": "cloudflare"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The upload routes must:
|
||||
|
||||
- Require `authMiddleware`.
|
||||
- Validate the user can submit to the exercise.
|
||||
- Validate the question belongs to the exercise.
|
||||
- Validate the question type is `VIDEO_RECORDING`.
|
||||
- Use the configured storage provider abstraction where possible.
|
||||
- Generate Cloudflare-compatible upload URLs for the video bucket.
|
||||
- Create a pending `assets` row during upload initialization.
|
||||
- Finalize the asset only after the browser upload succeeds.
|
||||
- Return durable answer data from completion without a playback URL.
|
||||
|
||||
### 4.1 Playback URL Contract
|
||||
|
||||
Playback URLs are generated on demand after access checks.
|
||||
|
||||
Proposed endpoint:
|
||||
|
||||
```text
|
||||
GET /course/:courseId/exercise/:exerciseId/submission/:submissionId/question/:questionId/video-recording/playback
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"assetId": "asset_uuid",
|
||||
"playbackUrl": "https://...",
|
||||
"expiresAt": "2026-04-29T12:10:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Students can request playback for their own active answer.
|
||||
- Teachers can request playback for submissions they are allowed to review.
|
||||
- Playback URLs must be short-lived.
|
||||
- The API should resolve playback by `assetId` first and fall back to `storageKey` only if needed for migration or repair.
|
||||
|
||||
### 5. Storage
|
||||
|
||||
Recommended storage namespace:
|
||||
|
||||
```text
|
||||
exercise-recordings/{organizationId}/{courseId}/{exerciseId}/{questionId}/{submissionOrProfileId}/{uuid}.{ext}
|
||||
```
|
||||
|
||||
Storage metadata:
|
||||
|
||||
- organization id
|
||||
- course id
|
||||
- exercise id
|
||||
- question id
|
||||
- profile/group member id
|
||||
- mime type
|
||||
- byte size
|
||||
- duration seconds
|
||||
- created at
|
||||
|
||||
Media manager integration is required for v1:
|
||||
|
||||
- Create an `assets` row with `kind = 'video'`.
|
||||
- Create the asset as `pending` during upload initialization.
|
||||
- Mark the asset `active` only after upload completion succeeds.
|
||||
- Create an `asset_usages` row with `target_type = 'submission_answer'` after the answer is accepted.
|
||||
- Store `assetId` in the answer payload.
|
||||
|
||||
### 6. Submission Persistence
|
||||
|
||||
The answer codec serializes `VideoRecordingAnswerData` into the existing answer storage path.
|
||||
|
||||
Required payload fields:
|
||||
|
||||
- `type`
|
||||
- `assetId`
|
||||
- `storageKey`
|
||||
- `mimeType`
|
||||
- `size`
|
||||
- `durationSeconds`
|
||||
- `recordedAt`
|
||||
- `uploadedAt`
|
||||
|
||||
### 7. Cleanup
|
||||
|
||||
Cleanup is part of the v1 implementation because retakes can otherwise create unbounded storage waste.
|
||||
|
||||
Asset states:
|
||||
|
||||
| State | Meaning | Cleanup behavior |
|
||||
| --- | --- | --- |
|
||||
| `pending` | Upload URL was issued, but upload completion has not succeeded | Delete after short expiry window |
|
||||
| `active` | Asset is referenced by the latest accepted answer | Keep |
|
||||
| `superseded` | Asset was replaced by a newer accepted take before submission | Delete after retention window |
|
||||
| `abandoned` | Student recorded/uploaded but never submitted the exercise or removed the answer | Delete after retention window |
|
||||
| `orphaned` | Asset is not referenced by any answer or usage | Delete after retention window |
|
||||
| `deleted` | Object and asset metadata are deleted or tombstoned | Exclude from playback |
|
||||
|
||||
Rules:
|
||||
|
||||
- When a student accepts and uploads a new take, mark the previously accepted asset for that question and student as `superseded`.
|
||||
- Do not delete superseded assets synchronously in the student request path.
|
||||
- Scheduled cleanup deletes `pending` assets whose upload URL expired and no completion was received.
|
||||
- Scheduled cleanup deletes `superseded`, `abandoned`, and `orphaned` assets after the configured retention window.
|
||||
- Cleanup must verify the asset is not referenced by the latest answer payload before deleting the object.
|
||||
- Cleanup must remove or tombstone the `asset_usages` row and then delete the storage object.
|
||||
- Cleanup failures must be logged with `assetId`, `storageKey`, and reason.
|
||||
- The cleanup job should be idempotent so retries are safe.
|
||||
|
||||
Suggested defaults:
|
||||
|
||||
- Pending upload expiry: 24 hours.
|
||||
- Superseded/abandoned/orphaned retention: 7 days.
|
||||
|
||||
## Security and Privacy Requirements
|
||||
|
||||
1. Camera and microphone are only activated after explicit student action.
|
||||
2. Browser permission errors are handled without crashing the exercise.
|
||||
3. Upload URLs are short-lived.
|
||||
4. Upload keys are scoped to the authenticated student and question.
|
||||
5. Students cannot overwrite another student's recording.
|
||||
6. Students cannot submit a recording answer for a question they cannot access.
|
||||
7. Teacher playback must respect course/org access controls.
|
||||
8. Playback URLs must be generated server-side for review.
|
||||
9. The system must not expose raw storage credentials to the browser.
|
||||
10. Retake metadata must not be shown to teachers unless product explicitly decides to expose it.
|
||||
|
||||
## Analytics and Observability
|
||||
|
||||
Track:
|
||||
|
||||
- Recording permission denied.
|
||||
- Recording started.
|
||||
- Recording stopped manually.
|
||||
- Recording auto-stopped at max duration.
|
||||
- Recording retaken.
|
||||
- Recording accepted.
|
||||
- Upload started.
|
||||
- Upload succeeded.
|
||||
- Upload failed.
|
||||
- Cleanup queued.
|
||||
- Cleanup succeeded.
|
||||
- Cleanup failed.
|
||||
- Submission with video recording answer completed.
|
||||
|
||||
Operational metrics:
|
||||
|
||||
- Upload failure rate.
|
||||
- Average recording duration.
|
||||
- Average upload time.
|
||||
- Storage consumed by video recording answers.
|
||||
- Orphaned recording count and bytes.
|
||||
- Superseded recording count and bytes.
|
||||
- Cleanup deleted bytes.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase A: Foundation
|
||||
|
||||
- Add question type registry entry.
|
||||
- Add answer data and codec.
|
||||
- Add seed/reference data.
|
||||
- Add tests for serialization and registry.
|
||||
|
||||
### Phase B: Recording UI
|
||||
|
||||
- Add edit renderer with max duration setting.
|
||||
- Add take renderer with camera preview, recording, timer, playback, retake, and upload states.
|
||||
- Add preview/submission renderers.
|
||||
- Add translations and picker metadata.
|
||||
|
||||
### Phase C: Upload and Persistence
|
||||
|
||||
- Add recording-specific upload initialization and completion routes.
|
||||
- Create pending media assets during upload initialization.
|
||||
- Store answer payload with `assetId`, `storageKey`, duration, and metadata.
|
||||
- Ensure submission validation and manual grading behavior work.
|
||||
|
||||
### Phase D: Playback and Cleanup
|
||||
|
||||
- Add teacher playback in submission review.
|
||||
- Add on-demand playback URL generation.
|
||||
- Add cleanup job for pending, superseded, abandoned, and orphaned recording assets.
|
||||
|
||||
### Phase E: Hardening
|
||||
|
||||
- Add analytics and error logging.
|
||||
- Add browser compatibility QA.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. A teacher can add a video recording question to an exercise.
|
||||
2. A teacher can set max recording duration for the question.
|
||||
3. A student can start recording from the browser without choosing a file.
|
||||
4. The live recording frame is circular.
|
||||
5. The recording timer is visible while recording.
|
||||
6. Recording auto-stops at the configured max duration.
|
||||
7. A student can stop early.
|
||||
8. A student can replay the recording before accepting.
|
||||
9. A student can retake the recording multiple times.
|
||||
10. Only the latest accepted recording is saved as the answer.
|
||||
11. The accepted recording uploads to Cloudflare-backed storage.
|
||||
12. The answer payload stores `assetId`, `storageKey`, and metadata, not a persisted playback URL.
|
||||
13. Required video recording questions block submission until upload succeeds.
|
||||
14. Teachers can play the submitted recording during review through an on-demand playback URL.
|
||||
15. Superseded, abandoned, and orphaned recording assets are cleaned up asynchronously.
|
||||
16. Video recording questions require manual grading.
|
||||
17. Unsupported browsers show a clear message and do not offer file upload fallback.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Browser MediaRecorder differences | Recording may fail on some devices | Compatibility testing and MIME fallback list |
|
||||
| Large uploads on mobile networks | Student frustration and failed submissions | Short max duration, upload progress, retry |
|
||||
| Storage cost growth from retakes | Higher Cloudflare costs | Store only the latest answer reference, mark replaced assets as superseded, and run scheduled cleanup |
|
||||
| Playback URL expiry | Teacher cannot play older answers | Store `assetId` and `storageKey`, then generate playback URLs on demand |
|
||||
| Permission denial | Student cannot answer | Clear permission guidance and teacher visibility into incomplete answers |
|
||||
| Privacy expectations | Sensitive student video data mishandled | Strict access checks, short-lived URLs, retention policy |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Browser `MediaRecorder` and `getUserMedia` support.
|
||||
- Cloudflare-compatible video object storage.
|
||||
- Existing storage/presign utility or storage provider abstraction.
|
||||
- Shared question renderer architecture.
|
||||
- Existing manual grading flow.
|
||||
- Dashboard translation infrastructure.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Teacher-configurable retake limit.
|
||||
- One-take-only mode.
|
||||
- Automatic thumbnail generation.
|
||||
- Audio-only recording question type.
|
||||
- Speech transcription.
|
||||
- AI-assisted rubric feedback.
|
||||
- Recording countdown before capture starts.
|
||||
- Organization-level recording retention settings.
|
||||
- Upload resume for unstable mobile networks.
|
||||
- Media manager integration for all recording answers.
|
||||
@@ -44,7 +44,7 @@ Today, ClassroomIO has no outbound webhook product:
|
||||
- `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`
|
||||
- `prd/public-api [DONE]/README.md`
|
||||
|
||||
## Product Goals
|
||||
|
||||
|
||||
Reference in New Issue
Block a user