Files
classroomio/prd/programs [DONE]

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:

  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

// 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 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

// 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.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

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.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:

<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.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":

{
  "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.