1268 lines
52 KiB
Markdown
1268 lines
52 KiB
Markdown
# Programs PRD
|
|
|
|
## 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.
|
|
|
|
### Overview
|
|
|
|
Programs help you group multiple courses into one structured learning experience. Instead of treating every course as a separate training track, you can organize related courses, learners, and communication into a single program.
|
|
|
|
This is especially useful when training is delivered as a journey rather than a one-off course.
|
|
|
|
### When To Use Programs
|
|
|
|
Use Programs when:
|
|
|
|
- Learners should take more than one course as part of the same track.
|
|
- The same group of learners should be enrolled across multiple courses.
|
|
- You want one place to manage the people in that training path.
|
|
- You need a shared announcement space across several related courses.
|
|
- You want learners to experience the training as one program instead of a flat course list.
|
|
|
|
If you only need to deliver one standalone course, a course by itself may be enough. If you are running a structured pathway, a cohort, or a multi-step curriculum, Programs are usually the better fit.
|
|
|
|
### Common Use Cases
|
|
|
|
#### Employee onboarding
|
|
|
|
Create one program for new hires and include all required onboarding courses inside it.
|
|
|
|
Example:
|
|
|
|
- Company culture
|
|
- Security awareness
|
|
- IT setup training
|
|
- Role-specific onboarding
|
|
|
|
This makes it easy to enroll each new employee once and give them access to the full onboarding path.
|
|
|
|
#### Certification or compliance training
|
|
|
|
Use a program when learners must complete several courses to earn a certification or complete a compliance requirement.
|
|
|
|
Example:
|
|
|
|
- Safety fundamentals
|
|
- Practical assessment prep
|
|
- Final certification review
|
|
|
|
This keeps the full certification journey together and makes communication easier for instructors and learners.
|
|
|
|
#### Bootcamps and cohort-based learning
|
|
|
|
Programs work well for cohorts that move through a sequence of courses together.
|
|
|
|
Example:
|
|
|
|
- Week 1 foundations
|
|
- Week 2 intermediate material
|
|
- Week 3 project work
|
|
- Week 4 final review
|
|
|
|
The program becomes the hub for that cohort.
|
|
|
|
#### Customer education
|
|
|
|
If you train customers on your product in stages, Programs can bundle those courses into one learning path.
|
|
|
|
Example:
|
|
|
|
- Product basics
|
|
- Admin training
|
|
- Reporting and analytics
|
|
- Advanced workflows
|
|
|
|
This gives customers a clearer path through your learning content.
|
|
|
|
#### Partner or channel enablement
|
|
|
|
Programs can package the training a reseller, implementation partner, or external team needs to complete.
|
|
|
|
Example:
|
|
|
|
- Brand overview
|
|
- Sales certification
|
|
- Technical onboarding
|
|
- Support processes
|
|
|
|
This makes it easier to assign the same learning experience to many partner groups.
|
|
|
|
#### Internal academy or role-based development
|
|
|
|
If your organization runs structured learning for managers, sales reps, engineers, or support teams, a program can become the container for each pathway.
|
|
|
|
Example:
|
|
|
|
- New manager program
|
|
- Sales ramp-up program
|
|
- Support excellence program
|
|
|
|
### What A Program Includes
|
|
|
|
A program usually brings together four things:
|
|
|
|
- Courses: the learning content learners should complete.
|
|
- Members: the people assigned to the program.
|
|
- Roles: who can manage the program and who is participating in it.
|
|
- Newsfeed: a shared place for updates and announcements.
|
|
|
|
### How Programs Work
|
|
|
|
At a high level, Programs work like this:
|
|
|
|
1. Create a program.
|
|
2. Add the courses that belong in that program.
|
|
3. Add learners and tutors.
|
|
4. Use the program newsfeed for updates and communication.
|
|
5. Learners open the program and access the related courses from one place.
|
|
|
|
Programs help reduce repetitive admin work because you manage the training track as one unit instead of handling every course separately.
|
|
|
|
### Setting Up A Program
|
|
|
|
#### Step 1: Create the program
|
|
|
|
Start from the Programs section in your organization dashboard and create a new program.
|
|
|
|
Typical information to add:
|
|
|
|
- Program name
|
|
- Program description
|
|
|
|
Choose a name learners will understand immediately. Good program names are specific and outcome-based, such as:
|
|
|
|
- New Hire Onboarding
|
|
- Safety Certification Track
|
|
- Customer Success Bootcamp
|
|
- Manager Essentials Program
|
|
|
|
#### Step 2: Add courses
|
|
|
|
Once the program exists, add the courses that make up the learning path.
|
|
|
|
You can use this for:
|
|
|
|
- A short 2-course sequence
|
|
- A multi-week curriculum
|
|
- A full certification path
|
|
|
|
Try to keep the program focused. A strong program usually represents one coherent goal rather than a random bundle of unrelated courses.
|
|
|
|
#### Step 3: Add people
|
|
|
|
After adding courses, assign the people who should participate in the program.
|
|
|
|
This usually includes:
|
|
|
|
- Learners who need access to the training
|
|
- Tutors or trainers who will support the cohort
|
|
|
|
The main advantage here is that you do not need to think course by course. You can manage participation at the program level.
|
|
|
|
#### Step 4: Start communication
|
|
|
|
Use the program newsfeed to post updates that matter across the whole learning path.
|
|
|
|
This is useful for:
|
|
|
|
- Welcome messages
|
|
- Weekly announcements
|
|
- Schedule changes
|
|
- Program-wide reminders
|
|
- Encouragement before assessments or deadlines
|
|
|
|
### What Learners See
|
|
|
|
Learners can open Programs from their LMS view and see the training paths they have been enrolled in.
|
|
|
|
From there, they can:
|
|
|
|
- Open a program
|
|
- See the courses that belong to it
|
|
- Read program-wide updates in the newsfeed
|
|
- Move between related courses more easily
|
|
|
|
This makes the experience feel more guided and intentional than a flat list of separate courses.
|
|
|
|
### Roles Inside A Program
|
|
|
|
Programs support different responsibilities for different people.
|
|
|
|
Typical roles include:
|
|
|
|
- Admins: manage the program and its settings.
|
|
- Tutors: support delivery and help manage learners.
|
|
- Students: participate in the program and take the courses.
|
|
|
|
This makes Programs suitable for both internal teams and external learning groups.
|
|
|
|
### Program Statuses
|
|
|
|
Programs can move through different statuses as they progress.
|
|
|
|
#### Active
|
|
|
|
Use this when the program is currently running and available to learners.
|
|
|
|
#### Inactive
|
|
|
|
Use this when you want to keep the program and its structure but do not want it actively shown to learners in the same way as a live program.
|
|
|
|
#### Archived
|
|
|
|
Use this when the program is no longer part of your active training catalog and should be treated as historical.
|
|
|
|
### Best Practices
|
|
|
|
#### Build programs around one goal
|
|
|
|
Each program should represent one clear outcome, audience, or training pathway.
|
|
|
|
Good examples:
|
|
|
|
- Sales onboarding
|
|
- Compliance recertification
|
|
- Frontend engineering bootcamp
|
|
|
|
Less effective examples:
|
|
|
|
- Miscellaneous courses
|
|
- Team training library
|
|
|
|
#### Keep communication program-wide
|
|
|
|
Use the newsfeed for updates that affect everyone in the program. Keep course-specific communication inside the course when the message only applies to one course.
|
|
|
|
#### Name programs clearly
|
|
|
|
A learner should understand what a program is for just by reading its title.
|
|
|
|
Good naming patterns:
|
|
|
|
- Audience + goal
|
|
- Time period + goal
|
|
- Role + pathway
|
|
|
|
Examples:
|
|
|
|
- 2026 Graduate Trainee Program
|
|
- Support Team Onboarding
|
|
- ISO 27001 Readiness Track
|
|
|
|
#### Use programs for repeatable training
|
|
|
|
Programs are especially helpful when you run the same training structure more than once, such as onboarding every month or launching new cohorts every quarter.
|
|
|
|
### Examples Of Strong Program Designs
|
|
|
|
#### Example 1: New hire onboarding
|
|
|
|
Program name: New Hire Onboarding
|
|
|
|
Courses:
|
|
|
|
- Welcome to the company
|
|
- Security and compliance basics
|
|
- Internal tools training
|
|
- Team-specific workflow training
|
|
|
|
Why this works:
|
|
|
|
- One enrollment flow for every new hire
|
|
- One place for onboarding announcements
|
|
- A clear experience for learners
|
|
|
|
#### Example 2: Certification pathway
|
|
|
|
Program name: Health and Safety Certification
|
|
|
|
Courses:
|
|
|
|
- Safety fundamentals
|
|
- Incident reporting
|
|
- Practical safety review
|
|
- Final certification preparation
|
|
|
|
Why this works:
|
|
|
|
- Related content is grouped in one place
|
|
- Instructors can communicate with the full cohort
|
|
- Learners can follow a single path to completion
|
|
|
|
#### Example 3: Partner enablement
|
|
|
|
Program name: Certified Implementation Partner Program
|
|
|
|
Courses:
|
|
|
|
- Product foundations
|
|
- Solution setup
|
|
- Client handoff
|
|
- Support and escalation process
|
|
|
|
Why this works:
|
|
|
|
- External learners get a structured path
|
|
- The training team manages one program instead of many separate enrollments
|
|
|
|
### Frequently Asked Questions
|
|
|
|
#### Should I use one large program or several smaller ones?
|
|
|
|
Use one program when the courses belong to the same learner journey. Use separate programs when the audience, goal, or timeline is different.
|
|
|
|
#### Can a program contain multiple courses?
|
|
|
|
Yes. That is the main purpose of the feature.
|
|
|
|
#### Is a program better than a single course?
|
|
|
|
Not always. A single course is best for standalone training. A program is best when multiple courses should be delivered as one experience.
|
|
|
|
#### Who should use Programs most often?
|
|
|
|
Programs are especially useful for:
|
|
|
|
- Training organizations
|
|
- Companies onboarding employees
|
|
- Teams running certification tracks
|
|
- Bootcamps and cohort-based educators
|
|
- Customer education teams
|
|
|
|
Tip: if you already think about your training in terms of pathways, cohorts, tracks, academies, or onboarding journeys, Programs are likely the right structure for you.
|
|
|
|
## Purpose
|
|
|
|
Enable instructors and org admins to group multiple courses — along with a shared newsfeed — into a single **program**. Enrolling a student in a program auto-enrolls them in every course inside it, eliminating the need to manage per-course enrollment manually when delivering a structured learning path.
|
|
|
|
## Problem Statement
|
|
|
|
Currently, ClassroomIO has no entity above the individual course. Every course has its own independent enrollment roster. This creates several pain points:
|
|
|
|
- An instructor running a structured path (e.g., a bootcamp, onboarding track, or certification pathway) must enroll students in each constituent course one by one.
|
|
- There is no shared space for program-wide announcements or discussions that span multiple courses.
|
|
- Students cannot see a unified "program" view in the LMS — they only see a flat list of individual courses.
|
|
- Org admins have no way to bundle courses for a specific cohort of learners (e.g., "Q1 2026 New Hire Onboarding") without creating separate organizations or duplicating content.
|
|
|
|
## Confirmed Decisions
|
|
|
|
1. **Terminology**: "Program" in both the instructor-facing admin UI and the student-facing LMS.
|
|
2. **Auto-enrollment**: Joining a program automatically creates `groupmember` records for every course inside the program. Removing a student from a program does not remove them from individual course groups, preserving their submission and progress data.
|
|
3. **Newsfeed**: Each program has a newsfeed modelled exactly after `course_newsfeed` — same data shape (content, isPinned, reactions, comments), same service patterns, scoped to the program via `programId`.
|
|
4. **Many-to-many courses**: A course can belong to multiple programs. Courses and programs are independent entities linked by a join table.
|
|
5. **Org nav placement**: Programs are added to `/org/[slug]` as a top-level nav item alongside Courses and Community, following the exact same pattern used for those pages (`org-navigation.ts`).
|
|
6. **Program detail layout**: Opening a program follows the same `Sidebar.Provider` + `ProgramSidebar` + `ProgramHeader` pattern used for courses (`/courses/[id]`). The program detail lives at `/programs/[id]`.
|
|
7. **Sidebar items (v1)**: Newsfeed, Courses, People, Settings — in that order.
|
|
8. **Roles**: Program roles mirror the existing system (`ADMIN`/roleId 1, `TUTOR`/roleId 2, `STUDENT`/roleId 3). Program admins and tutors can manage the program; students get read access.
|
|
9. **Program status**: Programs can be `ACTIVE`, `INACTIVE`, or `ARCHIVED`. `INACTIVE` programs are hidden from students in the LMS but preserved for instructors. `ARCHIVED` programs are hidden everywhere.
|
|
10. **Course independence**: Courses in a program remain fully independent. A student enrolled in a program is a regular `groupmember` in each course — existing course pages, progress tracking, submissions, and grading are unchanged.
|
|
|
|
## Current-State Audit
|
|
|
|
| Capability | Current State | Notes |
|
|
| --- | --- | --- |
|
|
| Organization structure | `organization` → `group` (1:1 with `course`) → `course` | No intermediate grouping layer above course |
|
|
| Course enrollment | `groupmember` rows per course's `group` | Per-course, no bulk enrollment mechanism |
|
|
| Org admin nav | Courses, Community, Audience, Media, Tags nav items under `/org/[slug]` | "Programs" nav item needed |
|
|
| Newsfeed | `course_newsfeed` + `course_newsfeed_comment` tables, full service in `apps/api/src/services/newsfeed/newsfeed.ts` | Reuse shape; scope to program with `programId` |
|
|
| Student LMS view | Flat list of enrolled courses under `/lms/mylearning` | No program concept |
|
|
| Enrollment service | `enrollInCourse()` in `apps/api/src/services/course/invite.ts` | Must be called per-course when adding program member |
|
|
|
|
## Product Goals
|
|
|
|
1. Org admins and instructors can create a program, add courses to it, and manage program membership from the admin dashboard.
|
|
2. Adding a student to a program auto-enrolls them in all courses currently in the program.
|
|
3. Adding a new course to an existing program auto-enrolls all current program students in that course.
|
|
4. Each program has a newsfeed (posts, reactions, comments) visible to all program members, writable by admins and tutors.
|
|
5. Admins and tutors can view and manage all program members from a People page.
|
|
6. Students can see their enrolled programs in the LMS with a hub showing all courses and the newsfeed.
|
|
7. Programs with no students, and courses outside any program, continue to work exactly as they do today (zero regression).
|
|
|
|
## Non-Goals (v1)
|
|
|
|
- **Shared file library** — not in v1.
|
|
- **Program-level community Q&A** — v2.
|
|
- Program-level invite links (v1 enrollment is admin-managed).
|
|
- Program-specific exercise deadlines or content overrides.
|
|
- Program progress analytics aggregated across all courses.
|
|
- Removing a student from a program also unenrolling them from individual courses (data preservation is the default).
|
|
- Program templates or program cloning.
|
|
- Public/discoverable program landing pages.
|
|
- Billing or pricing at the program level.
|
|
|
|
## Data Sources Checked
|
|
|
|
- `packages/db/src/schema.ts` — `course_newsfeed`/`course_newsfeed_comment` shape to mirror
|
|
- `apps/api/src/services/newsfeed/newsfeed.ts` — newsfeed service pattern to replicate
|
|
- `apps/api/src/services/course/invite.ts` — enrollment flow (`enrollInCourse`)
|
|
- `apps/api/src/routes/course/course.ts` — course router pattern
|
|
- `apps/api/src/app.ts` — top-level router registration
|
|
- `packages/db/src/queries/course/people.ts` — group member management
|
|
- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` — where to add Programs nav item
|
|
- `apps/dashboard/src/routes/org/[slug]/+layout.svelte` — org layout pattern (Sidebar.Provider + OrgSidebar)
|
|
- `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte` — courses listing pattern to mirror
|
|
- `apps/dashboard/src/routes/courses/[id]/+layout.svelte` — course detail layout pattern to replicate
|
|
- `apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte` — sidebar pattern
|
|
- `apps/dashboard/src/lib/features/course/pages/people.svelte` — people page pattern
|
|
|
|
---
|
|
|
|
## Functional Requirements
|
|
|
|
### 1. Program CRUD (Admin)
|
|
|
|
- Org admins can create a program with: name (required), description (optional), cover image (optional).
|
|
- Programs can be updated (name, description, cover image, status).
|
|
- Archiving a program sets `status = 'ARCHIVED'` and hides it from all views. Archived programs can be restored.
|
|
- A program belongs to exactly one organization.
|
|
|
|
### 2. Courses Page (`/programs/[id]/courses`)
|
|
|
|
- Org admins and program tutors can add existing org courses to a program.
|
|
- A course can belong to multiple programs simultaneously.
|
|
- Adding a course to a program triggers auto-enrollment: all existing `STUDENT` program members are enrolled in the new course (a `groupmember` row is created in the course's `group` for each student not already enrolled).
|
|
- Removing a course from a program does not unenroll any students. Their progress and submissions are preserved.
|
|
- Courses within a program have no explicit ordering; they are displayed by insertion order (`addedAt` ascending).
|
|
|
|
**Admin/tutor view**:
|
|
- Ordered list of course rows: cover image thumbnail, course title, description excerpt, lesson count.
|
|
- "Add Course" button opens a searchable dropdown of org courses not already in this program.
|
|
- "Remove" button per course with a confirmation dialog noting no students are unenrolled.
|
|
|
|
**Student view** (LMS): same list with individual progress bar per course; clicking navigates to the course.
|
|
|
|
### 3. People Page (`/programs/[id]/people`)
|
|
|
|
Admin/tutor only — students do not see this sidebar item.
|
|
|
|
- Table of all `program_member` rows joined to `profile`.
|
|
- Columns: Avatar + Full Name, Email (obscured, same pattern as course people page), Role badge (Admin / Tutor / Student), Actions.
|
|
- Search input: client-side filter by name or email.
|
|
- Role filter dropdown: All / Admin / Tutor / Student.
|
|
- "Add Members" button: opens a modal with email input + role selector. Supports batch add (array of `{ email, roleId }` pairs), same pattern as the course invitation modal.
|
|
- Per-row actions: change role (dropdown), remove member (with confirmation noting course enrollments are preserved).
|
|
|
|
### 4. Newsfeed Page (`/programs/[id]/newsfeed`)
|
|
|
|
Modelled exactly after `course_newsfeed`. Same data shape, same service patterns, same sanitization, scoped to `programId`.
|
|
|
|
- Feed of posts by admins/tutors, ordered newest-first with pinned posts at the top.
|
|
- Each post: author avatar, author name, timestamp, sanitized HTML content, pin indicator (if pinned).
|
|
- Reaction bar: clap, smile, thumbsup, thumbsdown with counts. Clicking a reaction toggles the current user's entry in that reaction array.
|
|
- Comment thread below each post: author avatar, name, timestamp, content. Comment input field at bottom (visible to all members).
|
|
- "New Post" button (admin/tutor only) opens a rich-text editor.
|
|
- Edit/delete controls on own posts (admin/tutor). Org admins can delete any post.
|
|
- Students can react and comment but cannot create top-level posts.
|
|
|
|
### 5. Settings Page (`/programs/[id]/settings`)
|
|
|
|
Admin only — students and tutors do not see this sidebar item.
|
|
|
|
- **General section**: Program name (text input), Description (textarea), Cover Image (upload via existing presigned upload flow).
|
|
- **Status section**: Status select (Active / Inactive / Archived). Inactive hides the program from students. Setting to Archived requires a confirmation dialog.
|
|
- Save button submits a `PUT /program/:programId`.
|
|
|
|
### 6. Org Admin — Programs Listing (`/org/[slug]/programs`)
|
|
|
|
- "Programs" top-level nav item added to `org-navigation.ts` directly after "Courses", same structure as every other item (title, path, icon, matchPattern).
|
|
- Listing page shows all non-archived programs: name, cover image, course count, member count, status badge.
|
|
- "Create Program" button opens a creation form/modal.
|
|
- Clicking a program navigates to `/programs/[id]`.
|
|
- Follows the exact file and component structure of `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte`.
|
|
|
|
### 7. Program Detail — Sidebar Layout
|
|
|
|
Opening a program follows the **exact same pattern** as opening a course (`/courses/[id]/+layout.svelte`):
|
|
|
|
```
|
|
Sidebar.Provider
|
|
├── ProgramSidebar (programId, path)
|
|
└── Sidebar.Inset
|
|
├── ProgramHeader (program name + breadcrumb back to /org/[slug]/programs)
|
|
└── <slot /> (child routes)
|
|
```
|
|
|
|
**ProgramSidebar** — four items, role-gated, using the same `Sidebar.Group` / `Sidebar.Menu` / `Sidebar.MenuItem` primitives as `course-sidebar-navigation.svelte`:
|
|
|
|
| # | Label | Icon | Path | Visible to |
|
|
| --- | --- | --- | --- | --- |
|
|
| 1 | Newsfeed | `Newspaper` | `/programs/[id]/newsfeed` | All members |
|
|
| 2 | Courses | `BookOpen` | `/programs/[id]/courses` | All members |
|
|
| 3 | People | `Users` | `/programs/[id]/people` | Admin + Tutor only |
|
|
| 4 | Settings | `Settings` | `/programs/[id]/settings` | Admin only |
|
|
|
|
### 8. LMS — My Programs
|
|
|
|
- "Programs" nav item in the LMS sidebar, shown conditionally only when `enrolledPrograms.length > 0`, positioned between "My Learning" and "Exercises".
|
|
- `/lms/programs` lists all ACTIVE programs the student is enrolled in: program name, cover image, course count, description.
|
|
- Clicking a program opens `/lms/programs/[programId]` — the program hub with two tabs: **Courses** (list by insertion order with per-course progress bars) and **Newsfeed** (read + react + comment; no post creation).
|
|
|
|
### 9. Access Control
|
|
|
|
- Only `ADMIN`/`TUTOR` program members (or org admins) can manage program content and membership.
|
|
- Students can read the newsfeed and course list, react to and comment on newsfeed posts.
|
|
- An `INACTIVE` program is hidden from students in the LMS; all data is preserved and accessible to admins/tutors.
|
|
|
|
---
|
|
|
|
## Technical Design
|
|
|
|
### Data Model
|
|
|
|
#### New Enum: `PROGRAM_STATUS`
|
|
|
|
```typescript
|
|
// packages/db/src/schema.ts
|
|
export const programStatus = pgEnum('PROGRAM_STATUS', ['ACTIVE', 'INACTIVE', 'ARCHIVED']);
|
|
```
|
|
|
|
#### New Table: `program`
|
|
|
|
```typescript
|
|
// packages/db/src/schema.ts
|
|
export const program = pgTable(
|
|
'program',
|
|
{
|
|
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
|
|
organizationId: uuid('organization_id').notNull(),
|
|
name: varchar().notNull(),
|
|
description: text(),
|
|
coverImage: text('cover_image'),
|
|
status: programStatus().default('ACTIVE').notNull(),
|
|
createdByProfileId: uuid('created_by_profile_id')
|
|
},
|
|
(table) => [
|
|
foreignKey({
|
|
columns: [table.organizationId],
|
|
foreignColumns: [organization.id],
|
|
name: 'program_organization_id_fkey'
|
|
}).onDelete('cascade'),
|
|
foreignKey({
|
|
columns: [table.createdByProfileId],
|
|
foreignColumns: [profile.id],
|
|
name: 'program_created_by_profile_id_fkey'
|
|
}),
|
|
index('idx_program_organization_id').on(table.organizationId)
|
|
]
|
|
);
|
|
```
|
|
|
|
#### New Table: `program_course`
|
|
|
|
```typescript
|
|
export const programCourse = pgTable(
|
|
'program_course',
|
|
{
|
|
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
|
|
programId: uuid('program_id').notNull(),
|
|
courseId: uuid('course_id').notNull(),
|
|
addedAt: timestamp('added_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
|
},
|
|
(table) => [
|
|
foreignKey({
|
|
columns: [table.programId],
|
|
foreignColumns: [program.id],
|
|
name: 'program_course_program_id_fkey'
|
|
}).onDelete('cascade'),
|
|
foreignKey({
|
|
columns: [table.courseId],
|
|
foreignColumns: [course.id],
|
|
name: 'program_course_course_id_fkey'
|
|
}).onDelete('cascade'),
|
|
unique('program_course_program_id_course_id_unique').on(table.programId, table.courseId),
|
|
index('idx_program_course_program_id').on(table.programId)
|
|
]
|
|
);
|
|
```
|
|
|
|
#### New Table: `program_member`
|
|
|
|
```typescript
|
|
export const programMember = pgTable(
|
|
'program_member',
|
|
{
|
|
id: uuid().default(sql`gen_random_uuid()`).primaryKey().notNull(),
|
|
programId: uuid('program_id').notNull(),
|
|
profileId: uuid('profile_id'),
|
|
roleId: integer('role_id').notNull(),
|
|
email: text(),
|
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
|
},
|
|
(table) => [
|
|
foreignKey({
|
|
columns: [table.programId],
|
|
foreignColumns: [program.id],
|
|
name: 'program_member_program_id_fkey'
|
|
}).onDelete('cascade'),
|
|
foreignKey({
|
|
columns: [table.profileId],
|
|
foreignColumns: [profile.id],
|
|
name: 'program_member_profile_id_fkey'
|
|
}),
|
|
foreignKey({
|
|
columns: [table.roleId],
|
|
foreignColumns: [role.id],
|
|
name: 'program_member_role_id_fkey'
|
|
}),
|
|
unique('program_member_program_id_profile_id_unique').on(table.programId, table.profileId),
|
|
index('idx_program_member_program_id').on(table.programId),
|
|
index('idx_program_member_profile_id').on(table.profileId)
|
|
]
|
|
);
|
|
```
|
|
|
|
#### New Table: `program_newsfeed`
|
|
|
|
Mirrors `course_newsfeed` exactly. `authorId` references `program_member.id` (same as `course_newsfeed.authorId` → `groupmember.id`).
|
|
|
|
```typescript
|
|
export const programNewsfeed = pgTable(
|
|
'program_newsfeed',
|
|
{
|
|
id: uuid().defaultRandom().primaryKey().notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
|
authorId: uuid('author_id'),
|
|
programId: uuid('program_id'),
|
|
content: text(),
|
|
reaction: jsonb()
|
|
.default({ clap: [], smile: [], thumbsup: [], thumbsdown: [] })
|
|
.$type<{ clap: string[]; smile: string[]; thumbsup: string[]; thumbsdown: string[] }>(),
|
|
isPinned: boolean('is_pinned').default(false).notNull()
|
|
},
|
|
(table) => [
|
|
foreignKey({
|
|
columns: [table.authorId],
|
|
foreignColumns: [programMember.id],
|
|
name: 'program_newsfeed_author_id_fkey'
|
|
}),
|
|
foreignKey({
|
|
columns: [table.programId],
|
|
foreignColumns: [program.id],
|
|
name: 'program_newsfeed_program_id_fkey'
|
|
}).onDelete('cascade'),
|
|
index('idx_program_newsfeed_program_id').on(table.programId)
|
|
]
|
|
);
|
|
```
|
|
|
|
#### New Table: `program_newsfeed_comment`
|
|
|
|
Mirrors `course_newsfeed_comment`.
|
|
|
|
```typescript
|
|
export const programNewsfeedComment = pgTable(
|
|
'program_newsfeed_comment',
|
|
{
|
|
id: bigint({ mode: 'number' }).primaryKey()
|
|
.generatedByDefaultAsIdentity({ name: 'program_newsfeed_comment_id_seq' }),
|
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
|
authorId: uuid('author_id'),
|
|
content: text(),
|
|
programNewsfeedId: uuid('program_newsfeed_id')
|
|
},
|
|
(table) => [
|
|
foreignKey({
|
|
columns: [table.authorId],
|
|
foreignColumns: [programMember.id],
|
|
name: 'program_newsfeed_comment_author_id_fkey'
|
|
}),
|
|
foreignKey({
|
|
columns: [table.programNewsfeedId],
|
|
foreignColumns: [programNewsfeed.id],
|
|
name: 'program_newsfeed_comment_program_newsfeed_id_fkey'
|
|
}).onDelete('cascade')
|
|
]
|
|
);
|
|
```
|
|
|
|
#### Relationship Diagram
|
|
|
|
```
|
|
organization
|
|
└── program
|
|
├── program_course ──────────────────────→ course
|
|
│ (addedAt) └── group → groupmember ← (auto-created)
|
|
├── program_member ──────────────────────→ profile
|
|
│ (roleId: ADMIN/TUTOR/STUDENT)
|
|
├── program_newsfeed
|
|
│ (content, isPinned, reaction JSONB)
|
|
│ └── program_newsfeed_comment
|
|
└── [community Q&A — v2]
|
|
```
|
|
|
|
---
|
|
|
|
## Access Control Logic
|
|
|
|
Two middleware functions in `apps/api/src/middleware/`:
|
|
|
|
### `programAccessMiddleware` (read access)
|
|
|
|
Used on all `GET /program/:programId/*` routes.
|
|
|
|
```typescript
|
|
// apps/api/src/middleware/program-access.ts
|
|
export async function programAccessMiddleware(c, next) {
|
|
const { programId } = c.req.param();
|
|
const user = c.get('user')!;
|
|
const isMember = await isProgramMember(programId, user.profileId);
|
|
const isOrgAdmin = await isOrgAdminByProgramId(programId, user.profileId);
|
|
if (!isMember && !isOrgAdmin) throw new AppError('Forbidden', ErrorCodes.FORBIDDEN, 403);
|
|
await next();
|
|
}
|
|
```
|
|
|
|
### `programAdminMiddleware` (write access)
|
|
|
|
Used on all mutating routes.
|
|
|
|
```typescript
|
|
// apps/api/src/middleware/program-admin.ts
|
|
export async function programAdminMiddleware(c, next) {
|
|
const { programId } = c.req.param();
|
|
const user = c.get('user')!;
|
|
const role = await getProgramMemberRole(programId, user.profileId);
|
|
const isOrgAdmin = await isOrgAdminByProgramId(programId, user.profileId);
|
|
if (!isOrgAdmin && (!role || role > 2)) throw new AppError('Forbidden', ErrorCodes.FORBIDDEN, 403);
|
|
await next();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API and Route Plan
|
|
|
|
### Validation Layer
|
|
|
|
File: `packages/utils/src/validation/program/program.ts`
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
|
|
export const ZCreateProgram = z.object({
|
|
name: z.string().min(1).max(255),
|
|
description: z.string().max(2000).optional(),
|
|
coverImage: z.string().url().optional()
|
|
});
|
|
export type TCreateProgram = z.infer<typeof ZCreateProgram>;
|
|
|
|
export const ZUpdateProgram = z.object({
|
|
name: z.string().min(1).max(255).optional(),
|
|
description: z.string().max(2000).nullable().optional(),
|
|
coverImage: z.string().url().nullable().optional(),
|
|
status: z.enum(['ACTIVE', 'INACTIVE', 'ARCHIVED']).optional()
|
|
});
|
|
export type TUpdateProgram = z.infer<typeof ZUpdateProgram>;
|
|
|
|
export const ZAddCourseToProgram = z.object({
|
|
courseId: z.string().uuid(),
|
|
});
|
|
export type TAddCourseToProgram = z.infer<typeof ZAddCourseToProgram>;
|
|
|
|
export const ZAddProgramMembers = z.object({
|
|
members: z.array(
|
|
z.object({
|
|
email: z.string().email(),
|
|
roleId: z.number().int().min(1).max(3)
|
|
})
|
|
).min(1)
|
|
});
|
|
export type TAddProgramMembers = z.infer<typeof ZAddProgramMembers>;
|
|
|
|
export const ZUpdateProgramMember = z.object({
|
|
roleId: z.number().int().min(1).max(3)
|
|
});
|
|
export type TUpdateProgramMember = z.infer<typeof ZUpdateProgramMember>;
|
|
|
|
export const ZCreateProgramNewsfeed = z.object({
|
|
content: z.string().min(1),
|
|
isPinned: z.boolean().optional()
|
|
});
|
|
export type TCreateProgramNewsfeed = z.infer<typeof ZCreateProgramNewsfeed>;
|
|
|
|
export const ZUpdateProgramNewsfeed = z.object({
|
|
content: z.string().min(1).optional(),
|
|
isPinned: z.boolean().optional()
|
|
});
|
|
export type TUpdateProgramNewsfeed = z.infer<typeof ZUpdateProgramNewsfeed>;
|
|
|
|
export const ZUpdateProgramReaction = z.object({
|
|
reaction: z.object({
|
|
clap: z.array(z.string()),
|
|
smile: z.array(z.string()),
|
|
thumbsup: z.array(z.string()),
|
|
thumbsdown: z.array(z.string())
|
|
})
|
|
});
|
|
export type TUpdateProgramReaction = z.infer<typeof ZUpdateProgramReaction>;
|
|
|
|
export const ZCreateProgramNewsfeedComment = z.object({
|
|
content: z.string().min(1)
|
|
});
|
|
export type TCreateProgramNewsfeedComment = z.infer<typeof ZCreateProgramNewsfeedComment>;
|
|
```
|
|
|
|
Export from: `packages/utils/src/validation/program/index.ts` and re-export from `packages/utils/src/validation/index.ts`.
|
|
|
|
### Query Layer
|
|
|
|
File: `packages/db/src/queries/program/program.ts`
|
|
|
|
| Function | Description |
|
|
| --- | --- |
|
|
| `createProgram(data)` | Insert into `program`, return created row |
|
|
| `getProgramById(programId)` | Single program with member count and course count |
|
|
| `getProgramsByOrg(orgId)` | All non-archived programs for org, ordered by `createdAt` desc |
|
|
| `updateProgram(programId, data)` | Update name, description, coverImage, status |
|
|
| `getProgramMemberRole(programId, profileId)` | Return `roleId` for the given profile in the program, or null |
|
|
| `isProgramMember(programId, profileId)` | Boolean check |
|
|
| `isOrgAdminByProgramId(programId, profileId)` | Joins `program → organization → organizationmember` to check admin role |
|
|
| `addProgramMember(programId, profileId, roleId, email)` | Insert `program_member` row (upsert on conflict) |
|
|
| `removeProgramMember(programId, memberId)` | Delete `program_member` by `id` |
|
|
| `getProgramMembers(programId)` | All members joined to `profile` for display |
|
|
| `getEnrolledProgramsByProfile(orgId, profileId)` | All `ACTIVE` programs where profile is a program member, for LMS |
|
|
| `addCourseToProgram(programId, courseId)` | Insert `program_course` row |
|
|
| `removeCourseFromProgram(programId, courseId)` | Delete `program_course` row |
|
|
| `getCoursesByProgram(programId)` | All courses in program ordered by `addedAt` asc, joined to `course` |
|
|
| `getStudentMemberIdsByProgram(programId)` | All `profileId` values with `roleId = 3` in program (for auto-enrollment) |
|
|
| `createProgramNewsfeed(data)` | Insert `program_newsfeed` |
|
|
| `getProgramNewsfeed(programId)` | All posts ordered by `isPinned desc, createdAt desc`, joined to `program_member` + `profile` |
|
|
| `updateProgramNewsfeed(feedId, data)` | Update content / isPinned |
|
|
| `updateProgramNewsfeedReaction(feedId, reaction)` | Update reaction JSONB |
|
|
| `deleteProgramNewsfeed(feedId)` | Delete row |
|
|
| `createProgramNewsfeedComment(data)` | Insert `program_newsfeed_comment` |
|
|
| `getProgramNewsfeedComments(feedId)` | All comments for a post, joined to `program_member` + `profile` |
|
|
| `deleteProgramNewsfeedComment(commentId)` | Delete row |
|
|
|
|
Export from: `packages/db/src/queries/program/index.ts`
|
|
|
|
### Service Layer
|
|
|
|
File: `apps/api/src/services/program/program.ts`
|
|
|
|
#### `createProgram(orgId, profileId, data: TCreateProgram)`
|
|
- Insert `program` row.
|
|
- Add creator as `program_member` with `roleId = 1` (ADMIN).
|
|
- Return the new program.
|
|
|
|
#### `addCourseToProgram(programId, courseId)`
|
|
- Verify program exists and course belongs to the same org.
|
|
- Insert `program_course` row (error if already linked).
|
|
- Fetch all current STUDENT program members (`getStudentMemberIdsByProgram`).
|
|
- For each student profile, check `groupmember` status in the course's group; if not already enrolled, call `enrollInCourse`.
|
|
- All writes in a single transaction.
|
|
- Return updated course list.
|
|
|
|
#### `removeCourseFromProgram(programId, courseId)`
|
|
- Delete `program_course` row.
|
|
- No changes to `groupmember` records.
|
|
|
|
#### `addMembersToProgram(programId, members: TAddProgramMembers['members'])`
|
|
Transaction:
|
|
1. For each member, resolve `profileId` from `email` (same pattern as `addGroupMember`).
|
|
2. Insert `program_member` row (skip if already a member).
|
|
3. If `roleId = 3` (STUDENT), fetch all courses (`getCoursesByProgram`). For each course, check if the student is already a `groupmember`; if not, insert a `groupmember` row with `roleId = 3`.
|
|
|
|
#### `removeMemberFromProgram(programId, memberId)`
|
|
- Delete `program_member` row by `id`.
|
|
- Leave all `groupmember` rows in individual courses intact.
|
|
|
|
#### `createNewsfeedPost(programId, profileId, data: TCreateProgramNewsfeed)`
|
|
- Sanitize HTML content (same sanitization used in `course_newsfeed`).
|
|
- Resolve `program_member` row for caller to get `authorId`.
|
|
- Insert `program_newsfeed` row.
|
|
- Return new post.
|
|
|
|
#### `updateNewsfeedReaction(feedId, reaction: TUpdateProgramReaction['reaction'])`
|
|
- Update the `reaction` JSONB column directly (same pattern as `updateNewsfeedReactionService`).
|
|
|
|
#### `createNewsfeedComment(feedId, profileId, data: TCreateProgramNewsfeedComment)`
|
|
- Sanitize content.
|
|
- Resolve `program_member` row for caller.
|
|
- Insert `program_newsfeed_comment` row.
|
|
- Return new comment.
|
|
|
|
### Route Layer
|
|
|
|
File: `apps/api/src/routes/program/program.ts`
|
|
|
|
All routes require `authMiddleware`. Routes scoped to `:programId` also apply `programAccessMiddleware` (read) or `programAdminMiddleware` (write).
|
|
|
|
| Method | Path | Middleware | Description |
|
|
| --- | --- | --- | --- |
|
|
| `GET` | `/program` | `authMiddleware` | List org's programs |
|
|
| `POST` | `/program` | `authMiddleware` + orgAdmin | Create program |
|
|
| `GET` | `/program/:programId` | `programAccessMiddleware` | Get program detail |
|
|
| `PUT` | `/program/:programId` | `programAdminMiddleware` | Update program |
|
|
| `DELETE` | `/program/:programId` | orgAdmin | Archive program |
|
|
| `GET` | `/program/:programId/courses` | `programAccessMiddleware` | List courses in program |
|
|
| `POST` | `/program/:programId/courses` | `programAdminMiddleware` | Add course + auto-enroll students |
|
|
| `DELETE` | `/program/:programId/courses/:courseId` | `programAdminMiddleware` | Remove course |
|
|
| `GET` | `/program/:programId/members` | `programAdminMiddleware` | List members with profile info |
|
|
| `POST` | `/program/:programId/members` | `programAdminMiddleware` | Batch-add members + auto-enroll students |
|
|
| `PUT` | `/program/:programId/members/:memberId` | `programAdminMiddleware` | Update member role |
|
|
| `DELETE` | `/program/:programId/members/:memberId` | `programAdminMiddleware` | Remove member |
|
|
| `GET` | `/program/:programId/newsfeed` | `programAccessMiddleware` | List newsfeed posts |
|
|
| `POST` | `/program/:programId/newsfeed` | `programAdminMiddleware` | Create post |
|
|
| `PUT` | `/program/:programId/newsfeed/:feedId` | `programAdminMiddleware` | Update post |
|
|
| `PATCH` | `/program/:programId/newsfeed/:feedId/reaction` | `programAccessMiddleware` | Update reaction |
|
|
| `DELETE` | `/program/:programId/newsfeed/:feedId` | `programAdminMiddleware` | Delete post |
|
|
| `GET` | `/program/:programId/newsfeed/:feedId/comments` | `programAccessMiddleware` | List comments |
|
|
| `POST` | `/program/:programId/newsfeed/:feedId/comments` | `programAccessMiddleware` | Create comment |
|
|
| `DELETE` | `/program/:programId/newsfeed/:feedId/comments/:commentId` | `programAccessMiddleware` | Delete comment |
|
|
|
|
#### Route Registration
|
|
|
|
```typescript
|
|
// apps/api/src/routes/program/index.ts
|
|
export * from './program';
|
|
|
|
// apps/api/src/app.ts
|
|
import { programRouter } from '@api/routes/program';
|
|
export const app = new Hono()
|
|
// ... existing routes ...
|
|
.route('/program', programRouter);
|
|
```
|
|
|
|
#### LMS Enrolled Programs Route
|
|
|
|
Add to `apps/api/src/routes/organization/organization.ts`:
|
|
|
|
```
|
|
GET /organization/programs/enrolled
|
|
```
|
|
|
|
Returns all `ACTIVE` programs where the current user is a program member, including the course list with per-course progress. Used by LMS "My Programs" pages.
|
|
|
|
#### Build Verification
|
|
|
|
```bash
|
|
pnpm --filter @cio/utils build
|
|
pnpm --filter @cio/db build
|
|
pnpm --filter @cio/api build
|
|
```
|
|
|
|
---
|
|
|
|
## Frontend Plan (Dashboard)
|
|
|
|
### Follow AGENTS.md patterns strictly
|
|
|
|
- All types in `utils/types.ts`, inferred from the API. Never define types in `.svelte.ts` files.
|
|
- API logic in API classes only — components stay thin.
|
|
- All UI copy in translation keys (`en.json`), referenced with `$t()` / `t()`.
|
|
- Use `@cio/ui/custom/*-field` wrappers for forms; compose with `@cio/ui/base/field` primitives for settings/editor screens.
|
|
- Theme color classes prefixed with `ui:` (e.g., `ui:text-muted-foreground`).
|
|
|
|
### Request Types
|
|
|
|
File: `apps/dashboard/src/lib/features/program/utils/types.ts`
|
|
|
|
```typescript
|
|
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
|
|
|
export type ListProgramsRequest = typeof classroomio.program.$get;
|
|
export type CreateProgramRequest = typeof classroomio.program.$post;
|
|
export type GetProgramRequest = (typeof classroomio.program)[':programId']['$get'];
|
|
export type UpdateProgramRequest = (typeof classroomio.program)[':programId']['$put'];
|
|
export type ListProgramCoursesRequest = (typeof classroomio.program)[':programId']['courses']['$get'];
|
|
export type AddCourseToProgramRequest = (typeof classroomio.program)[':programId']['courses']['$post'];
|
|
export type ListProgramMembersRequest = (typeof classroomio.program)[':programId']['members']['$get'];
|
|
export type AddProgramMembersRequest = (typeof classroomio.program)[':programId']['members']['$post'];
|
|
export type ListProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$get'];
|
|
export type CreateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$post'];
|
|
|
|
export type ListProgramsSuccess = Extract<InferResponseType<ListProgramsRequest>, { success: true }>;
|
|
export type ProgramItem = ListProgramsSuccess['data'][number];
|
|
|
|
export type GetProgramSuccess = Extract<InferResponseType<GetProgramRequest>, { success: true }>;
|
|
export type ProgramDetail = GetProgramSuccess['data'];
|
|
|
|
export type ListProgramCoursesSuccess = Extract<InferResponseType<ListProgramCoursesRequest>, { success: true }>;
|
|
export type ProgramCourse = ListProgramCoursesSuccess['data'][number];
|
|
|
|
export type ListProgramMembersSuccess = Extract<InferResponseType<ListProgramMembersRequest>, { success: true }>;
|
|
export type ProgramMember = ListProgramMembersSuccess['data'][number];
|
|
|
|
export type ListProgramNewsfeedSuccess = Extract<InferResponseType<ListProgramNewsfeedRequest>, { success: true }>;
|
|
export type ProgramNewsfeedPost = ListProgramNewsfeedSuccess['data'][number];
|
|
|
|
export type EnrolledProgramsRequest = typeof classroomio.organization.programs.enrolled['$get'];
|
|
export type EnrolledProgramsSuccess = Extract<InferResponseType<EnrolledProgramsRequest>, { success: true }>;
|
|
export type EnrolledProgram = EnrolledProgramsSuccess['data'][number];
|
|
```
|
|
|
|
### API Class
|
|
|
|
File: `apps/dashboard/src/lib/features/program/api/program.svelte.ts`
|
|
|
|
```typescript
|
|
class ProgramApi extends BaseApiWithErrors {
|
|
programs = $state<ProgramItem[]>([]);
|
|
currentProgram = $state<ProgramDetail | null>(null);
|
|
courses = $state<ProgramCourse[]>([]);
|
|
members = $state<ProgramMember[]>([]);
|
|
newsfeed = $state<ProgramNewsfeedPost[]>([]);
|
|
loading = $state(false);
|
|
|
|
async list() { /* GET /program */ }
|
|
async get(programId: string) { /* GET /program/:programId */ }
|
|
async create(data: TCreateProgram) { /* POST /program */ }
|
|
async update(programId: string, data: TUpdateProgram) { /* PUT /program/:programId */ }
|
|
async archive(programId: string) { /* DELETE /program/:programId */ }
|
|
|
|
async listCourses(programId: string) { /* GET /program/:programId/courses */ }
|
|
async addCourse(programId: string, data: TAddCourseToProgram) { /* POST */ }
|
|
async removeCourse(programId: string, courseId: string) { /* DELETE */ }
|
|
|
|
async listMembers(programId: string) { /* GET /program/:programId/members */ }
|
|
async addMembers(programId: string, data: TAddProgramMembers) { /* POST */ }
|
|
async removeMember(programId: string, memberId: string) { /* DELETE */ }
|
|
async updateMemberRole(programId: string, memberId: string, data: TUpdateProgramMember) { /* PUT */ }
|
|
|
|
async listNewsfeed(programId: string) { /* GET /program/:programId/newsfeed */ }
|
|
async createPost(programId: string, data: TCreateProgramNewsfeed) { /* POST */ }
|
|
async updatePost(programId: string, feedId: string, data: TUpdateProgramNewsfeed) { /* PUT */ }
|
|
async updateReaction(programId: string, feedId: string, data: TUpdateProgramReaction) { /* PATCH */ }
|
|
async deletePost(programId: string, feedId: string) { /* DELETE */ }
|
|
async listComments(programId: string, feedId: string) { /* GET */ }
|
|
async createComment(programId: string, feedId: string, data: TCreateProgramNewsfeedComment) { /* POST */ }
|
|
async deleteComment(programId: string, feedId: string, commentId: string) { /* DELETE */ }
|
|
}
|
|
|
|
export const programApi = new ProgramApi();
|
|
```
|
|
|
|
### Org Slug Integration
|
|
|
|
**Nav item** — add to `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts` directly after "Courses":
|
|
|
|
```typescript
|
|
{
|
|
title: t.get('program.title'),
|
|
path: `/org/${slug}/programs`,
|
|
icon: LayoutGrid,
|
|
matchPattern: /\/org\/[^/]+\/programs/
|
|
}
|
|
```
|
|
|
|
**Listing page**: `apps/dashboard/src/routes/org/[slug]/programs/+page.svelte`
|
|
- Mirrors `apps/dashboard/src/routes/org/[slug]/courses/+page.svelte` exactly in structure.
|
|
- Calls `programApi.list()` on mount.
|
|
- Grid/list of `ProgramItem` cards: name, cover image, course count, member count, status badge.
|
|
- "Create Program" button → creation form/modal.
|
|
- Clicking a card navigates to `/programs/[id]`.
|
|
|
|
### Program Detail — Route Structure
|
|
|
|
```
|
|
apps/dashboard/src/routes/programs/
|
|
└── [id]/
|
|
├── +layout.svelte ← Sidebar.Provider + ProgramSidebar + ProgramHeader
|
|
├── +layout.server.ts ← auth check, load programId
|
|
├── +page.svelte ← redirect to ./newsfeed
|
|
├── newsfeed/
|
|
│ └── +page.svelte
|
|
├── courses/
|
|
│ └── +page.svelte
|
|
├── people/
|
|
│ └── +page.svelte
|
|
└── settings/
|
|
└── +page.svelte
|
|
```
|
|
|
|
**`+layout.svelte`** mirrors `courses/[id]/+layout.svelte`:
|
|
|
|
```svelte
|
|
<Sidebar.Provider>
|
|
<ProgramSidebar {programId} {path} />
|
|
<Sidebar.Inset>
|
|
<ProgramHeader />
|
|
<slot />
|
|
</Sidebar.Inset>
|
|
</Sidebar.Provider>
|
|
```
|
|
|
|
### ProgramSidebar Component
|
|
|
|
File: `apps/dashboard/src/lib/features/program/components/sidebar/program-sidebar-navigation.svelte`
|
|
|
|
Mirrors `course-sidebar-navigation.svelte` using the same `Sidebar.Group` / `Sidebar.Menu` / `Sidebar.MenuItem` primitives:
|
|
|
|
| # | Label key | Icon | Path | Visible to |
|
|
| --- | --- | --- | --- | --- |
|
|
| 1 | `program.newsfeed` | `Newspaper` | `/programs/[id]/newsfeed` | All members |
|
|
| 2 | `program.courses` | `BookOpen` | `/programs/[id]/courses` | All members |
|
|
| 3 | `program.people` | `Users` | `/programs/[id]/people` | Admin + Tutor only |
|
|
| 4 | `program.settings` | `Settings` | `/programs/[id]/settings` | Admin only |
|
|
|
|
### Feature Pages
|
|
|
|
All page components live in `apps/dashboard/src/lib/features/program/pages/`. SvelteKit route files are thin wrappers that import and render the feature page component.
|
|
|
|
| Page | Feature file | Key UI elements |
|
|
| --- | --- | --- |
|
|
| Newsfeed | `newsfeed.svelte` | Post feed (pinned first), "New Post" editor (admin/tutor), reaction bar, comment threads |
|
|
| Courses | `courses.svelte` | Course list (insertion order), "Add Course" dropdown, remove per course (admin/tutor) |
|
|
| People | `people.svelte` | Member table with search + role filter, "Add Members" modal, role change + remove per row |
|
|
| Settings | `settings.svelte` | Name/description/cover image form, status select, save button |
|
|
|
|
### LMS API Class
|
|
|
|
File: `apps/dashboard/src/lib/features/lms/api/program.svelte.ts`
|
|
|
|
```typescript
|
|
class LmsProgramApi extends BaseApiWithErrors {
|
|
enrolledPrograms = $state<EnrolledProgram[]>([]);
|
|
loading = $state(false);
|
|
|
|
async listEnrolled() { /* GET /organization/programs/enrolled */ }
|
|
}
|
|
|
|
export const lmsProgramApi = new LmsProgramApi();
|
|
```
|
|
|
|
### LMS Pages
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `apps/dashboard/src/routes/lms/programs/+page.svelte` | My enrolled programs list |
|
|
| `apps/dashboard/src/routes/lms/programs/[programId]/+page.svelte` | Program hub: Courses tab + Newsfeed tab |
|
|
|
|
Feature pages: `apps/dashboard/src/lib/features/lms/pages/program/`
|
|
|
|
### Navigation Updates
|
|
|
|
- **Org admin sidebar**: add "Programs" to `org-navigation.ts` directly after "Courses".
|
|
- **LMS sidebar**: add "Programs" nav item, shown only when `enrolledPrograms.length > 0`, positioned between "My Learning" and "Exercises".
|
|
|
|
### Translations
|
|
|
|
File: `apps/dashboard/src/lib/utils/translations/en.json` — add under `"program"`:
|
|
|
|
```json
|
|
{
|
|
"program": {
|
|
"title": "Programs",
|
|
"my_programs": "My Programs",
|
|
"create": "Create Program",
|
|
"create_description": "Group multiple courses under one program for your learners.",
|
|
"name": "Program Name",
|
|
"description": "Description",
|
|
"cover_image": "Cover Image",
|
|
"status": "Status",
|
|
"active": "Active",
|
|
"inactive": "Inactive",
|
|
"archived": "Archived",
|
|
"newsfeed": "Newsfeed",
|
|
"courses": "Courses",
|
|
"people": "People",
|
|
"settings": "Settings",
|
|
"add_course": "Add Course",
|
|
"remove_course": "Remove Course",
|
|
"add_members": "Add Members",
|
|
"remove_member": "Remove Member",
|
|
"course_count": "{count} courses",
|
|
"student_count": "{count} students",
|
|
"new_post": "New Post",
|
|
"write_post": "Write an announcement...",
|
|
"no_programs": "No programs yet. Create one to get started.",
|
|
"no_courses": "No courses in this program yet.",
|
|
"no_members": "No members yet. Add students or tutors.",
|
|
"no_posts": "No posts yet.",
|
|
"archive_confirm": "Archiving this program will hide it from all views. Are you sure?",
|
|
"auto_enroll_note": "Adding this course will enroll all current program students in it.",
|
|
"remove_course_note": "Removing this course will not unenroll any students.",
|
|
"remove_member_note": "Removing this member will not unenroll them from individual courses."
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
### Phase 1: Database + Schema
|
|
1. Add `programStatus` enum to `packages/db/src/schema.ts`.
|
|
2. Add `program`, `programCourse`, `programMember`, `programNewsfeed`, `programNewsfeedComment` tables to `packages/db/src/schema.ts`.
|
|
3. Run `pnpm --filter @cio/db build`.
|
|
|
|
### Phase 2: Validation + Queries
|
|
1. Create `packages/utils/src/validation/program/program.ts` with all Zod schemas.
|
|
2. Export from `packages/utils/src/validation/program/index.ts`; re-export from `packages/utils/src/validation/index.ts`.
|
|
3. Run `pnpm --filter @cio/utils build`.
|
|
4. Create `packages/db/src/queries/program/program.ts`.
|
|
5. Create `packages/db/src/queries/program/index.ts`.
|
|
6. Run `pnpm --filter @cio/db build`.
|
|
|
|
### Phase 3: Service + Routes
|
|
1. Create `apps/api/src/services/program/program.ts` (mirror newsfeed service for newsfeed functions; auto-enrollment transaction is most complex).
|
|
2. Create `apps/api/src/middleware/program-access.ts` and `apps/api/src/middleware/program-admin.ts`.
|
|
3. Add error codes to `apps/api/src/utils/errors.ts`: `PROGRAM_NOT_FOUND`, `PROGRAM_FORBIDDEN`, `COURSE_ALREADY_IN_PROGRAM`, `MEMBER_ALREADY_IN_PROGRAM`.
|
|
4. Create `apps/api/src/routes/program/program.ts`.
|
|
5. Create `apps/api/src/routes/program/index.ts`; register in `apps/api/src/app.ts`.
|
|
6. Add `GET /organization/programs/enrolled` to the organization router.
|
|
7. Run `pnpm --filter @cio/api build`.
|
|
|
|
### Phase 4: Org Listing Page
|
|
1. Add "Programs" nav item to `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`.
|
|
2. Add translation keys to `en.json`.
|
|
3. Create `apps/dashboard/src/routes/org/[slug]/programs/+page.svelte`.
|
|
4. Run `pnpm --filter @cio/dashboard build`.
|
|
|
|
### Phase 5: Program Detail (Admin)
|
|
1. Add request/response types to `apps/dashboard/src/lib/features/program/utils/types.ts`.
|
|
2. Create `apps/dashboard/src/lib/features/program/api/program.svelte.ts`.
|
|
3. Create `apps/dashboard/src/routes/programs/[id]/+layout.svelte` and `+layout.server.ts`.
|
|
4. Create `ProgramSidebar` and `ProgramHeader` components.
|
|
5. Create feature pages: `newsfeed.svelte`, `courses.svelte`, `people.svelte`, `settings.svelte`.
|
|
6. Create SvelteKit route files under `apps/dashboard/src/routes/programs/[id]/`.
|
|
7. Run `pnpm --filter @cio/dashboard build`.
|
|
|
|
### Phase 6: LMS
|
|
1. Create `apps/dashboard/src/lib/features/lms/api/program.svelte.ts`.
|
|
2. Create LMS feature pages under `apps/dashboard/src/lib/features/lms/pages/program/`.
|
|
3. Create SvelteKit route files under `apps/dashboard/src/routes/lms/programs/`.
|
|
4. Add "Programs" nav item to the LMS sidebar.
|
|
5. Run `pnpm --filter @cio/dashboard build`.
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. Org admin can create a program, add courses to it, and add students from the admin dashboard.
|
|
2. When a student is added to a program, `groupmember` rows are automatically created for every course in the program.
|
|
3. When a course is added to an existing program that already has students, all those students are enrolled in the new course.
|
|
4. Removing a student from a program does not remove their `groupmember` records in individual courses.
|
|
5. Removing a course from a program does not unenroll any students.
|
|
6. Program admins and tutors can create newsfeed posts; all members can react and comment; students cannot create top-level posts.
|
|
7. Pinned posts appear at the top of the newsfeed.
|
|
8. The People page shows all program members with search and role filter; only admins and tutors can see it.
|
|
9. Settings page lets admins update name, description, cover image, and status.
|
|
10. "Programs" nav item appears in the org admin sidebar under `/org/[slug]/programs`.
|
|
11. Students see a "Programs" section in the LMS sidebar only when enrolled in at least one program.
|
|
12. `INACTIVE` programs are hidden from the student LMS but accessible to admins and tutors.
|
|
13. `ARCHIVED` programs are hidden from all views.
|
|
14. Courses outside any program, and students enrolled independently, continue to work exactly as today (zero regression).
|
|
15. All user-facing strings use translation keys.
|
|
16. `pnpm --filter @cio/api build` and `pnpm --filter @cio/dashboard build` pass after implementation.
|
|
|
|
---
|
|
|
|
## Risks and Mitigations
|
|
|
|
- **Risk**: Auto-enrollment transaction for large programs (hundreds of students, many courses) times out or partially fails.
|
|
- **Mitigation**: Use a single transaction with Drizzle batch inserts. For very large programs (> 500 students), offload to a background job and return `202 Accepted` with a polling endpoint.
|
|
|
|
- **Risk**: A student already enrolled in a course independently causes duplicate `groupmember` rows (unique constraint on `(groupId, profileId)`).
|
|
- **Mitigation**: Use `INSERT ... ON CONFLICT DO NOTHING` in `addMembersToProgram`. Same idempotency guarantee as the existing enrollment service.
|
|
|
|
- **Risk**: Removing a course from a program confuses students who are still enrolled but no longer see it in the program hub.
|
|
- **Mitigation**: Show a confirmation dialog with the `program.remove_course_note` translation key: "Removing this course will not unenroll any students. They will still have access to it via My Learning."
|
|
|
|
- **Risk**: Org admin sidebar gets crowded with a new "Programs" top-level item.
|
|
- **Mitigation**: Place "Programs" directly below "Courses". Consider a "Learning" section header if more items are added in future.
|