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.
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:
- Create a program.
- Add the courses that belong in that program.
- Add learners and tutors.
- Use the program newsfeed for updates and communication.
- 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
- Terminology: "Program" in both the instructor-facing admin UI and the student-facing LMS.
- Auto-enrollment: Joining a program automatically creates
groupmemberrecords 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. - 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 viaprogramId. - Many-to-many courses: A course can belong to multiple programs. Courses and programs are independent entities linked by a join table.
- 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). - Program detail layout: Opening a program follows the same
Sidebar.Provider+ProgramSidebar+ProgramHeaderpattern used for courses (/courses/[id]). The program detail lives at/programs/[id]. - Sidebar items (v1): Newsfeed, Courses, People, Settings — in that order.
- 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. - Program status: Programs can be
ACTIVE,INACTIVE, orARCHIVED.INACTIVEprograms are hidden from students in the LMS but preserved for instructors.ARCHIVEDprograms are hidden everywhere. - Course independence: Courses in a program remain fully independent. A student enrolled in a program is a regular
groupmemberin 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
- Org admins and instructors can create a program, add courses to it, and manage program membership from the admin dashboard.
- Adding a student to a program auto-enrolls them in all courses currently in the program.
- Adding a new course to an existing program auto-enrolls all current program students in that course.
- Each program has a newsfeed (posts, reactions, comments) visible to all program members, writable by admins and tutors.
- Admins and tutors can view and manage all program members from a People page.
- Students can see their enrolled programs in the LMS with a hub showing all courses and the newsfeed.
- 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_commentshape to mirrorapps/api/src/services/newsfeed/newsfeed.ts— newsfeed service pattern to replicateapps/api/src/services/course/invite.ts— enrollment flow (enrollInCourse)apps/api/src/routes/course/course.ts— course router patternapps/api/src/app.ts— top-level router registrationpackages/db/src/queries/course/people.ts— group member managementapps/dashboard/src/lib/features/ui/navigation/org-navigation.ts— where to add Programs nav itemapps/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 mirrorapps/dashboard/src/routes/courses/[id]/+layout.svelte— course detail layout pattern to replicateapps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte— sidebar patternapps/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
STUDENTprogram members are enrolled in the new course (agroupmemberrow is created in the course'sgroupfor 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 (
addedAtascending).
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_memberrows joined toprofile. - 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.tsdirectly 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/programslists 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/TUTORprogram 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
INACTIVEprogram is hidden from students in the LMS; all data is preserved and accessible to admins/tutors.
Technical Design
Data Model
New Enum: PROGRAM_STATUS
// packages/db/src/schema.ts
export const programStatus = pgEnum('PROGRAM_STATUS', ['ACTIVE', 'INACTIVE', 'ARCHIVED']);
New Table: program
// 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
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
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).
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.
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.
// 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.
// 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
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
programrow. - Add creator as
program_memberwithroleId = 1(ADMIN). - Return the new program.
addCourseToProgram(programId, courseId)
- Verify program exists and course belongs to the same org.
- Insert
program_courserow (error if already linked). - Fetch all current STUDENT program members (
getStudentMemberIdsByProgram). - For each student profile, check
groupmemberstatus in the course's group; if not already enrolled, callenrollInCourse. - All writes in a single transaction.
- Return updated course list.
removeCourseFromProgram(programId, courseId)
- Delete
program_courserow. - No changes to
groupmemberrecords.
addMembersToProgram(programId, members: TAddProgramMembers['members'])
Transaction:
- For each member, resolve
profileIdfromemail(same pattern asaddGroupMember). - Insert
program_memberrow (skip if already a member). - If
roleId = 3(STUDENT), fetch all courses (getCoursesByProgram). For each course, check if the student is already agroupmember; if not, insert agroupmemberrow withroleId = 3.
removeMemberFromProgram(programId, memberId)
- Delete
program_memberrow byid. - Leave all
groupmemberrows in individual courses intact.
createNewsfeedPost(programId, profileId, data: TCreateProgramNewsfeed)
- Sanitize HTML content (same sanitization used in
course_newsfeed). - Resolve
program_memberrow for caller to getauthorId. - Insert
program_newsfeedrow. - Return new post.
updateNewsfeedReaction(feedId, reaction: TUpdateProgramReaction['reaction'])
- Update the
reactionJSONB column directly (same pattern asupdateNewsfeedReactionService).
createNewsfeedComment(feedId, profileId, data: TCreateProgramNewsfeedComment)
- Sanitize content.
- Resolve
program_memberrow for caller. - Insert
program_newsfeed_commentrow. - 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
// 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
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.tsfiles. - 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/*-fieldwrappers for forms; compose with@cio/ui/base/fieldprimitives 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
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
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":
{
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.svelteexactly in structure. - Calls
programApi.list()on mount. - Grid/list of
ProgramItemcards: 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:
<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
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.tsdirectly 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":
{
"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
- Add
programStatusenum topackages/db/src/schema.ts. - Add
program,programCourse,programMember,programNewsfeed,programNewsfeedCommenttables topackages/db/src/schema.ts. - Run
pnpm --filter @cio/db build.
Phase 2: Validation + Queries
- Create
packages/utils/src/validation/program/program.tswith all Zod schemas. - Export from
packages/utils/src/validation/program/index.ts; re-export frompackages/utils/src/validation/index.ts. - Run
pnpm --filter @cio/utils build. - Create
packages/db/src/queries/program/program.ts. - Create
packages/db/src/queries/program/index.ts. - Run
pnpm --filter @cio/db build.
Phase 3: Service + Routes
- Create
apps/api/src/services/program/program.ts(mirror newsfeed service for newsfeed functions; auto-enrollment transaction is most complex). - Create
apps/api/src/middleware/program-access.tsandapps/api/src/middleware/program-admin.ts. - Add error codes to
apps/api/src/utils/errors.ts:PROGRAM_NOT_FOUND,PROGRAM_FORBIDDEN,COURSE_ALREADY_IN_PROGRAM,MEMBER_ALREADY_IN_PROGRAM. - Create
apps/api/src/routes/program/program.ts. - Create
apps/api/src/routes/program/index.ts; register inapps/api/src/app.ts. - Add
GET /organization/programs/enrolledto the organization router. - Run
pnpm --filter @cio/api build.
Phase 4: Org Listing Page
- Add "Programs" nav item to
apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts. - Add translation keys to
en.json. - Create
apps/dashboard/src/routes/org/[slug]/programs/+page.svelte. - Run
pnpm --filter @cio/dashboard build.
Phase 5: Program Detail (Admin)
- Add request/response types to
apps/dashboard/src/lib/features/program/utils/types.ts. - Create
apps/dashboard/src/lib/features/program/api/program.svelte.ts. - Create
apps/dashboard/src/routes/programs/[id]/+layout.svelteand+layout.server.ts. - Create
ProgramSidebarandProgramHeadercomponents. - Create feature pages:
newsfeed.svelte,courses.svelte,people.svelte,settings.svelte. - Create SvelteKit route files under
apps/dashboard/src/routes/programs/[id]/. - Run
pnpm --filter @cio/dashboard build.
Phase 6: LMS
- Create
apps/dashboard/src/lib/features/lms/api/program.svelte.ts. - Create LMS feature pages under
apps/dashboard/src/lib/features/lms/pages/program/. - Create SvelteKit route files under
apps/dashboard/src/routes/lms/programs/. - Add "Programs" nav item to the LMS sidebar.
- Run
pnpm --filter @cio/dashboard build.
Acceptance Criteria
- Org admin can create a program, add courses to it, and add students from the admin dashboard.
- When a student is added to a program,
groupmemberrows are automatically created for every course in the program. - When a course is added to an existing program that already has students, all those students are enrolled in the new course.
- Removing a student from a program does not remove their
groupmemberrecords in individual courses. - Removing a course from a program does not unenroll any students.
- Program admins and tutors can create newsfeed posts; all members can react and comment; students cannot create top-level posts.
- Pinned posts appear at the top of the newsfeed.
- The People page shows all program members with search and role filter; only admins and tutors can see it.
- Settings page lets admins update name, description, cover image, and status.
- "Programs" nav item appears in the org admin sidebar under
/org/[slug]/programs. - Students see a "Programs" section in the LMS sidebar only when enrolled in at least one program.
INACTIVEprograms are hidden from the student LMS but accessible to admins and tutors.ARCHIVEDprograms are hidden from all views.- Courses outside any program, and students enrolled independently, continue to work exactly as today (zero regression).
- All user-facing strings use translation keys.
pnpm --filter @cio/api buildandpnpm --filter @cio/dashboard buildpass 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 Acceptedwith a polling endpoint.
- Mitigation: Use a single transaction with Drizzle batch inserts. For very large programs (> 500 students), offload to a background job and return
-
Risk: A student already enrolled in a course independently causes duplicate
groupmemberrows (unique constraint on(groupId, profileId)).- Mitigation: Use
INSERT ... ON CONFLICT DO NOTHINGinaddMembersToProgram. Same idempotency guarantee as the existing enrollment service.
- Mitigation: Use
-
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_notetranslation key: "Removing this course will not unenroll any students. They will still have access to it via My Learning."
- Mitigation: Show a confirmation dialog with the
-
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.