# Exercise Sections — Implementation Plan ## Delivery Phases The work is broken into 4 sequential phases. Each phase is independently shippable and testable. Phases must be completed in order because later phases depend on earlier ones. --- ## Phase 1: Database & Shared Types (Foundation) **Goal**: Schema changes, shared types, and validation — no UI or API behavior changes yet. ### Ticket 1.1: Drizzle Schema — `exercise_section` Table **Priority**: P0 **Estimate**: 1–2 hours **What to do**: 1. Open `packages/db/src/schema.ts`. 2. Add the `exerciseSection` table definition **above** the `question` table (because `question` will reference it): ```typescript export const exerciseSection = pgTable( 'exercise_section', { id: uuid() .default(sql`gen_random_uuid()`) .primaryKey() .notNull(), exerciseId: uuid('exercise_id').notNull(), title: varchar().notNull().default('Untitled Section'), description: text(), order: bigint({ mode: 'number' }).notNull().default(0), afterBehavior: jsonb('after_behavior') .notNull() .default({ action: 'continue' }), createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow() }, (table) => [ foreignKey({ columns: [table.exerciseId], foreignColumns: [exercise.id], name: 'exercise_section_exercise_id_fkey' }).onDelete('cascade'), index('idx_exercise_section_exercise_id').on(table.exerciseId) ] ); ``` 3. Add `sectionId` column to the existing `question` table definition. Find the `question` table object and add this column after `exerciseId`: ```typescript sectionId: uuid('section_id'), ``` 4. Add a foreign key constraint in the `question` table's constraint array: ```typescript foreignKey({ columns: [table.sectionId], foreignColumns: [exerciseSection.id], name: 'question_section_id_fkey' }).onDelete('set null') ``` 5. Add `sectionDisplayMode` column to the existing `exercise` table definition: ```typescript sectionDisplayMode: varchar('section_display_mode').default('one_question'), ``` 6. Run `pnpm --filter @cio/db build` and fix any type errors. **Files to change**: - `packages/db/src/schema.ts` **How to verify**: - `pnpm --filter @cio/db build` succeeds with no errors. --- ### Ticket 1.2: Database Migration Script **Priority**: P0 **Estimate**: 1 hour **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. 3. Review the generated migration carefully to ensure: - `section_id` on `question` is nullable (no `NOT NULL`). - `section_display_mode` on `exercise` has default `'one_question'`. - The `exercise_section` table has `ON DELETE CASCADE` for `exercise_id`. - The `question.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`. --- ### Ticket 1.3: Shared TypeScript Types **Priority**: P0 **Estimate**: 1 hour **What to do**: 1. Create `packages/question-types/src/exercise-section-types.ts`: ```typescript import type { ExerciseQuestionModel } from './exercise-types'; export type ExerciseSectionAfterAction = 'continue' | 'go_to_section' | 'submit'; export interface ExerciseSectionAfterBehavior { action: ExerciseSectionAfterAction; sectionId?: string; } export type ExerciseSectionDisplayMode = 'one_question' | 'all_questions'; export interface ExerciseSectionModel { id?: string; title: string; description?: string | null; order: number; afterBehavior: ExerciseSectionAfterBehavior; questions: ExerciseQuestionModel[]; } ``` 2. Export from `packages/question-types/src/index.ts`: ```typescript export * from './exercise-section-types'; ``` 3. Run `pnpm --filter @cio/question-types build`. **Files to create**: - `packages/question-types/src/exercise-section-types.ts` **Files to change**: - `packages/question-types/src/index.ts` **How to verify**: - `pnpm --filter @cio/question-types build` succeeds. - Types are importable: `import { ExerciseSectionModel } from '@cio/question-types'` resolves. --- ### Ticket 1.4: Zod Validation Schemas for Sections **Priority**: P0 **Estimate**: 1–2 hours **What to do**: 1. Create `packages/utils/src/validation/exercise/exercise-section.ts`: ```typescript 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('submit') }) ]); export const ZExerciseSection = z.object({ id: z.string().uuid().optional(), title: z.string().min(1, 'Section title is required'), description: z.string().optional().nullable(), order: z.number().int().min(0), afterBehavior: ZExerciseSectionAfterBehavior.default({ action: 'continue' }), questionIds: z.array(z.string()).optional() }); export const ZExerciseSectionCreate = ZExerciseSection.omit({ id: true }); export const ZExerciseSectionUpdate = ZExerciseSection.partial().required({ id: true }); export type TExerciseSection = z.infer; ``` 2. Update the existing exercise validation file to accept optional `sections` and `sectionDisplayMode`. Find the existing `ZExerciseUpdate` in `packages/utils/src/validation/exercise/exercise.ts` and extend it: ```typescript import { ZExerciseSection } from './exercise-section'; // Add to ZExerciseUpdate: sections: z.array(ZExerciseSection).optional(), sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional() ``` 3. Export from the validation index: ```typescript export * from './exercise-section'; ``` **Files to create**: - `packages/utils/src/validation/exercise/exercise-section.ts` **Files to change**: - `packages/utils/src/validation/exercise/exercise.ts` - `packages/utils/src/validation/exercise/index.ts` (or wherever exercise validation is exported) **How to verify**: - `pnpm --filter @cio/utils build` succeeds. - `ZExerciseSection.parse({ title: 'Test', order: 0 })` succeeds. - `ZExerciseSection.parse({ title: '', order: 0 })` fails with "Section title is required". --- ### Ticket 1.5: Database Query Functions for Sections **Priority**: P0 **Estimate**: 2–3 hours **What to do**: 1. Create `packages/db/src/queries/exercise/exercise-section.ts` with these functions: ```typescript import { db } from '../../db'; import { exerciseSection, question } from '../../schema'; import { eq, inArray, sql } from 'drizzle-orm'; export async function getExerciseSectionsByExerciseId(exerciseId: string) { return db .select() .from(exerciseSection) .where(eq(exerciseSection.exerciseId, exerciseId)) .orderBy(exerciseSection.order); } export async function getExerciseSectionsByExerciseIds(exerciseIds: string[]) { if (exerciseIds.length === 0) return []; return db .select() .from(exerciseSection) .where(inArray(exerciseSection.exerciseId, exerciseIds)) .orderBy(exerciseSection.order); } export async function createExerciseSections( sections: Array<{ exerciseId: string; title: string; description?: string | null; order: number; afterBehavior?: { action: string; sectionId?: string }; }> ) { if (sections.length === 0) return []; return db.insert(exerciseSection).values(sections).returning(); } export async function updateExerciseSection( sectionId: string, data: Partial<{ title: string; description: string | null; order: number; afterBehavior: { action: string; sectionId?: string }; }> ) { return db .update(exerciseSection) .set({ ...data, updatedAt: new Date().toISOString() }) .where(eq(exerciseSection.id, sectionId)) .returning(); } export async function deleteExerciseSection(sectionId: string) { return db.delete(exerciseSection).where(eq(exerciseSection.id, sectionId)); } export async function moveQuestionToSection(questionId: number, sectionId: string | null) { return db .update(question) .set({ sectionId }) .where(eq(question.id, questionId)); } export async function bulkMoveQuestionsToSection(questionIds: number[], sectionId: string | null) { if (questionIds.length === 0) return; return db .update(question) .set({ sectionId }) .where(inArray(question.id, questionIds)); } ``` 2. Export from the queries index file (create or update `packages/db/src/queries/exercise/index.ts`): ```typescript export * from './exercise-section'; ``` **Files to create**: - `packages/db/src/queries/exercise/exercise-section.ts` **Files to change**: - `packages/db/src/queries/exercise/index.ts` - `packages/db/src/queries/index.ts` (if needed for top-level re-export) **How to verify**: - `pnpm --filter @cio/db build` succeeds. - Functions are importable from `@db/queries`. --- ## Phase 2: API Layer **Goal**: API endpoints accept and return sections. No dashboard changes yet — testable via API calls. ### Ticket 2.1: Exercise Fetch — Return Sections **Priority**: P0 **Estimate**: 2–3 hours **What to do**: 1. Open the exercise fetch service/query (likely `packages/db/src/queries/exercise/exercise.ts` or `apps/api/src/services/exercise/exercise.ts`). 2. After fetching an exercise and its questions, also fetch sections: ```typescript 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. 4. Return the response shaped as: ```typescript { ...exercise, sectionDisplayMode: exercise.sectionDisplayMode, sections: sections.map((section) => ({ id: section.id, title: section.title, description: section.description, order: section.order, afterBehavior: section.afterBehavior, questions: questionsInSection // sorted by order })), questions: unsectionedQuestions // sorted by order } ``` 5. For backward compatibility, if there are no sections, `sections` is `[]` and all questions are in `questions`. **Files to change**: - Exercise fetch query/service file - Exercise route handler (response shape) **How to verify**: - `GET /course/:courseId/exercise/:exerciseId` returns `sections: []` for existing exercises. - After manually inserting a section and assigning questions to it, the endpoint returns the section with nested questions. --- ### Ticket 2.2: Exercise Update — Accept Sections **Priority**: P0 **Estimate**: 3–4 hours **What to do**: 1. Open the exercise update service (likely `apps/api/src/services/exercise/exercise.ts`). 2. Accept `sections` and `sectionDisplayMode` in the update payload. 3. Implement section upsert logic: ``` For each section in the payload: - If section has `id` and exists in DB → update title, description, 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 `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 `sections` is empty or omitted, don't touch existing sections. 5. Update `sectionDisplayMode` on the exercise if provided. **Files to change**: - `apps/api/src/services/exercise/exercise.ts` - `apps/api/src/routes/course/exercise.ts` (accept new fields in validator) **How to verify**: - `PUT /course/:courseId/exercise/:exerciseId` with `sections: [{ title: 'Part A', order: 0, questionIds: ['q1', 'q2'] }]` creates a section and assigns questions. - Sending the same request with an updated title updates the section. - Sending a request without the section deletes it. - `sectionDisplayMode` persists correctly. - Invalid `go_to_section` reference returns a 400 error. --- ### Ticket 2.3: Submission Fetch — Section-Grouped Response **Priority**: P1 **Estimate**: 2–3 hours **What to do**: 1. Open the submission fetch service (used by the grading/submissions views). 2. When returning submission data for an exercise that has sections: - Fetch sections for the exercise. - Group question answers by section. - Compute per-section subtotals (sum of points awarded) and max points (sum of question points). - Return a `sections` array with grouped answers alongside the existing flat data. 3. For exercises without sections, return the existing flat response (backward compatible). **Response shape** (when sections exist): ```typescript { // existing fields... sections: [ { sectionId: 'uuid', sectionTitle: 'Part A', subtotal: 15, maxPoints: 20, answers: [/* answers for questions in this section */] }, // ... ], totalScore: 35, maxScore: 50 } ``` **Files to change**: - Submission fetch service/query - Submission route handler **How to verify**: - Submission fetch for sectionless exercise returns existing shape unchanged. - Submission fetch for sectioned exercise returns `sections` with correct groupings and subtotals. --- ### Ticket 2.4: Clone Service — Support Sections **Priority**: P1 **Estimate**: 2–3 hours **What to do**: 1. Open `apps/api/src/services/course/clone.ts`. 2. In `cloneExercises`, after creating new exercises: ```typescript // 4a. Fetch sections for old exercises const oldExerciseIds = oldExercises.map((ex) => ex.id); const oldSections = await getExerciseSectionsByExerciseIds(oldExerciseIds); let sectionIdMap = new Map(); if (oldSections.length > 0) { // 4b. Create new sections with mapped exercise IDs const newSections = await createExerciseSections( oldSections.map((section) => ({ title: section.title, description: section.description, order: section.order, afterBehavior: section.afterBehavior, exerciseId: exerciseIdMap.get(section.exerciseId)! })) ); // 4c. Build section ID map oldSections.forEach((oldSection, index) => { sectionIdMap.set(oldSection.id, newSections[index].id); }); // 4d. Remap afterBehavior.sectionId 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 (remappedId) { await updateExerciseSection(newSection.id, { afterBehavior: { action: 'go_to_section', sectionId: remappedId } }); } } } } ``` 3. When inserting cloned questions, include `sectionId`: ```typescript sectionId: question.sectionId ? sectionIdMap.get(question.sectionId) ?? null : null ``` 4. Also copy `sectionDisplayMode` when creating the new exercise. **Files to change**: - `apps/api/src/services/course/clone.ts` **How to verify**: - Clone a course with sectioned exercises. - Verify cloned exercise has same number of sections with correct titles. - Verify questions are assigned to correct cloned sections. - Verify afterBehavior `sectionId` references point to the NEW section IDs (not old ones). - Verify `sectionDisplayMode` is preserved. --- ## Phase 3: Dashboard — Edit Mode **Goal**: Instructors can create, edit, reorder, and delete sections. Questions can be moved between sections. ### Ticket 3.1: Extend Exercise Store with Section State **Priority**: P0 **Estimate**: 2–3 hours **What to do**: 1. Open `apps/dashboard/src/lib/features/course/components/exercise/store.ts`. 2. Add the `ExerciseSectionState` interface and extend the `questionnaire` writable: ```typescript import type { ExerciseSectionAfterBehavior, ExerciseSectionDisplayMode } from '@cio/question-types'; export interface ExerciseSectionState { id: string; title: string; description: string | null; order: number; afterBehavior: ExerciseSectionAfterBehavior; isDirty?: boolean; deletedAt?: string; } ``` Add `sections` and `sectionDisplayMode` to the questionnaire writable's type and default value (see PRD Section 7.1). 3. Add helper functions (see PRD Section 7.4): - `handleAddSection()` - `handleRemoveSection(sectionId, moveQuestionsToSectionId?)` - `handleMoveQuestionToSection(questionId, sectionId)` - `handleUpdateSectionAfterBehavior(sectionId, afterBehavior)` - `getQuestionsForSection(questions, sectionId)` - `hasSections(sections)` - `handleReorderSections(orderedSectionIds)` 4. Update `handleAddQuestion()` to accept an optional `sectionId` parameter. When sections exist, the new question must have a `sectionId`. 5. Extend `Question` type in `apps/dashboard/src/lib/features/course/types.ts` to include `sectionId`. 6. Extend the metadata store to include `currentSectionIndex` and `sectionPhase`. **Files to change**: - `apps/dashboard/src/lib/features/course/components/exercise/store.ts` - `apps/dashboard/src/lib/features/course/types.ts` **How to verify**: - Import and call `handleAddSection()` — store updates with a new section. - `getQuestionsForSection()` returns filtered questions. - `handleMoveQuestionToSection()` updates the question's `sectionId`. - `pnpm --filter @cio/dashboard build` succeeds. --- ### Ticket 3.2: Section Header UI Component **Priority**: P0 **Estimate**: 2–3 hours **What to do**: 1. Create `packages/ui/src/custom/exercise-question/section-header.svelte`: ```typescript interface Props { title: string; description?: string | null; sectionNumber: number; totalSections: number; questionCount?: number; totalPoints?: number; mode: 'edit' | 'take' | 'preview' | 'view'; onTitleChange?: (title: string) => void; onDescriptionChange?: (description: string) => void; onDelete?: () => void; onMoveUp?: () => void; onMoveDown?: () => void; canMoveUp?: boolean; canMoveDown?: boolean; labels?: Record; } ``` 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". 3. In **take** mode, render: - Section title (read-only, prominent). - Section description (read-only). - Question count and total points. - No edit controls. 4. In **preview/view** mode, render: - Section title as a heading. - Section description below it. - No interactive controls. 5. Export from `packages/ui/src/custom/exercise-question/index.ts`. **Files to create**: - `packages/ui/src/custom/exercise-question/section-header.svelte` **Files to change**: - `packages/ui/src/custom/exercise-question/index.ts` **How to verify**: - Component renders correctly in all modes. - Edit mode: typing in title/description fires callbacks. - `pnpm --filter @cio/ui build` succeeds. --- ### Ticket 3.3: Section After-Behavior Dropdown **Priority**: P0 **Estimate**: 1–2 hours **What to do**: 1. Create `packages/ui/src/custom/exercise-question/section-after-behavior.svelte`: ```typescript interface Props { sectionIndex: number; allSections: Array<{ id: string; title: string; order: number }>; afterBehavior: ExerciseSectionAfterBehavior; onChange: (behavior: ExerciseSectionAfterBehavior) => void; labels: { afterSectionPrefix: string; continue: string; goToSection: string; submit: string; }; } ``` 2. Render a row at the bottom of each section card: - Label: "After section {N}" (left side). - A `