From ec61bfe7d9f858400e909d1173ae4995a7a82180 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 2 Jul 2026 12:10:31 +0100 Subject: [PATCH] feat(cohorts): rename Programs to Cohorts with data migration (#694) * feat(cohorts): rename Programs to Cohorts with data migration - Add cohort database tables and Drizzle migration alongside legacy program tables - Introduce migrate-programs-to-cohorts script to copy existing program data safely - Rename API routes (/cohort), services, queries, validation, and error codes - Rename dashboard feature module, routes (/cohorts), navigation, and translations - Update docs, emails, and internal compliance cron endpoints for cohort terminology - Keep legacy program tables in place for rollback until explicitly removed Co-authored-by: Rotimi Best * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- FEATURE_AUDIT.md | 28 +- README.md | 6 +- apps/api/src/app.ts | 4 +- .../{program-member.ts => cohort-member.ts} | 28 +- ...cohort-newsfeed-comment-author-or-team.ts} | 36 +- ...m-team-member.ts => cohort-team-member.ts} | 20 +- apps/api/src/routes/cohort/cohort.ts | 730 + apps/api/src/routes/cohort/index.ts | 1 + apps/api/src/routes/internal/compliance.ts | 24 +- apps/api/src/routes/program/index.ts | 1 - apps/api/src/routes/program/program.ts | 742 - .../{program/program.ts => cohort/cohort.ts} | 310 +- .../src/services/{program => cohort}/goal.ts | 140 +- apps/api/src/services/cohort/invite.ts | 52 + .../api/src/services/organization/audience.ts | 138 +- apps/api/src/services/organization/invite.ts | 42 +- apps/api/src/services/organization/search.ts | 16 +- apps/api/src/services/program/invite.ts | 52 - apps/api/src/services/v1/audience.ts | 2 +- apps/api/src/utils/org.ts | 6 +- .../lib/features/audience/pages/import.svelte | 52 +- .../lib/features/cohort/api/cohort.svelte.ts | 376 + .../lib/features/cohort/api/goal.svelte.ts | 129 + .../src/lib/features/cohort/api/index.ts | 3 + .../api/newsfeed.svelte.ts | 156 +- .../add-course-to-cohort-modal.svelte} | 30 +- .../components/cohort-card.svelte} | 16 +- .../components/cohort-filter-popover.svelte} | 36 +- .../components/cohort-header.svelte} | 4 +- .../components/create-cohort-modal.svelte} | 24 +- .../components/goals/goal-form-modal.svelte | 90 +- .../goals/goals-overview-tiles.svelte | 30 +- .../components/goals/goals-section.svelte | 54 +- .../components/goals/my-goals-widget.svelte | 36 +- .../components/invite-members-modal.svelte | 30 +- .../components/list.svelte | 36 +- .../components/newsfeed/card/card.svelte | 20 +- .../components/newsfeed/card/comments.svelte | 34 +- .../newsfeed/card/delete-confirmation.svelte | 8 +- .../components/newsfeed/card/header.svelte | 14 +- .../components/newsfeed/index.ts | 0 .../components/newsfeed/new-feed-modal.svelte | 40 +- .../sidebar/cohort-sidebar-navigation.svelte} | 28 +- .../components/sidebar/cohort-sidebar.svelte} | 10 +- .../cohort/components/sidebar/index.ts | 1 + .../pages/cohorts.svelte} | 20 +- .../src/lib/features/cohort/pages/index.ts | 1 + .../lib/features/cohort/utils/constants.ts | 4 + .../src/lib/features/cohort/utils/error.ts | 25 + .../src/lib/features/cohort/utils/types.ts | 65 + .../lib/features/program/api/goal.svelte.ts | 129 - .../src/lib/features/program/api/index.ts | 3 - .../features/program/api/program.svelte.ts | 376 - .../program/components/sidebar/index.ts | 1 - .../src/lib/features/program/pages/index.ts | 1 - .../lib/features/program/utils/constants.ts | 4 - .../src/lib/features/program/utils/error.ts | 25 - .../src/lib/features/program/utils/types.ts | 65 - .../lib/features/search/api/search.svelte.ts | 30 +- .../search/components/command-palette.svelte | 6 +- .../src/lib/features/search/utils/types.ts | 2 +- .../features/ui/navigation/lms-navigation.ts | 6 +- .../features/ui/navigation/org-navigation.ts | 6 +- .../src/lib/utils/translations/da.json | 484 +- .../src/lib/utils/translations/de.json | 484 +- .../src/lib/utils/translations/en.json | 500 +- .../src/lib/utils/translations/es.json | 484 +- .../src/lib/utils/translations/fr.json | 484 +- .../src/lib/utils/translations/hi.json | 484 +- .../src/lib/utils/translations/pl.json | 484 +- .../src/lib/utils/translations/pt.json | 484 +- .../src/lib/utils/translations/ru.json | 484 +- .../src/lib/utils/translations/vi.json | 484 +- .../[id]/+layout.server.ts | 2 +- .../{programs => cohorts}/[id]/+layout.svelte | 32 +- .../[id]/courses/+page.svelte | 28 +- .../[id]/newsfeed/+page.svelte | 64 +- .../[id]/people/+page.svelte | 34 +- .../[id]/settings/+page.svelte | 58 +- .../src/routes/(app)/lms/cohorts/+page.svelte | 52 + .../routes/(app)/lms/programs/+page.svelte | 52 - .../[slug]/audience/import/+page.server.ts | 14 +- .../org/[slug]/audience/import/+page.svelte | 2 +- .../(app)/org/[slug]/cohorts/+page.server.ts | 21 + .../(app)/org/[slug]/cohorts/+page.svelte | 156 + .../(app)/org/[slug]/programs/+page.server.ts | 21 - .../(app)/org/[slug]/programs/+page.svelte | 156 - .../org/[slug]/teams-overview/+page.svelte | 68 +- apps/docs/content/docs/cohorts.mdx | 54 + apps/docs/content/docs/meta.json | 2 +- apps/docs/content/docs/organization.mdx | 6 +- apps/docs/content/docs/programs.mdx | 67 - apps/docs/content/docs/student-dashboard.mdx | 18 +- packages/db/package.json | 1 + .../0002_majestic_masked_marvel.sql | 114 + .../db/src/migrations/meta/0002_snapshot.json | 13101 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/queries/cohort/cohort.ts | 761 + .../src/queries/{program => cohort}/goal.ts | 192 +- packages/db/src/queries/cohort/index.ts | 2 + packages/db/src/queries/index.ts | 2 +- packages/db/src/queries/program/index.ts | 2 - packages/db/src/queries/program/program.ts | 770 - packages/db/src/schema.ts | 251 + .../scripts/migrate-programs-to-cohorts.ts | 229 + ...al-reminder.ts => cohort-goal-reminder.ts} | 10 +- packages/email/src/emails/index.ts | 12 +- ...m-welcome.ts => student-cohort-welcome.ts} | 12 +- packages/email/src/utils/constants.ts | 4 +- packages/utils/src/constants/error-codes.ts | 32 +- .../{program/program.ts => cohort/cohort.ts} | 66 +- packages/utils/src/validation/cohort/index.ts | 1 + packages/utils/src/validation/index.ts | 2 +- .../src/validation/organization/audience.ts | 10 +- .../utils/src/validation/program/index.ts | 1 - 115 files changed, 19703 insertions(+), 6046 deletions(-) rename apps/api/src/middlewares/{program-member.ts => cohort-member.ts} (53%) rename apps/api/src/middlewares/{program-newsfeed-comment-author-or-team.ts => cohort-newsfeed-comment-author-or-team.ts} (62%) rename apps/api/src/middlewares/{program-team-member.ts => cohort-team-member.ts} (64%) create mode 100644 apps/api/src/routes/cohort/cohort.ts create mode 100644 apps/api/src/routes/cohort/index.ts delete mode 100644 apps/api/src/routes/program/index.ts delete mode 100644 apps/api/src/routes/program/program.ts rename apps/api/src/services/{program/program.ts => cohort/cohort.ts} (55%) rename apps/api/src/services/{program => cohort}/goal.ts (77%) create mode 100644 apps/api/src/services/cohort/invite.ts delete mode 100644 apps/api/src/services/program/invite.ts create mode 100644 apps/dashboard/src/lib/features/cohort/api/cohort.svelte.ts create mode 100644 apps/dashboard/src/lib/features/cohort/api/goal.svelte.ts create mode 100644 apps/dashboard/src/lib/features/cohort/api/index.ts rename apps/dashboard/src/lib/features/{program => cohort}/api/newsfeed.svelte.ts (62%) rename apps/dashboard/src/lib/features/{program/components/add-course-to-program-modal.svelte => cohort/components/add-course-to-cohort-modal.svelte} (81%) rename apps/dashboard/src/lib/features/{program/components/program-card.svelte => cohort/components/cohort-card.svelte} (70%) rename apps/dashboard/src/lib/features/{program/components/program-filter-popover.svelte => cohort/components/cohort-filter-popover.svelte} (78%) rename apps/dashboard/src/lib/features/{program/components/program-header.svelte => cohort/components/cohort-header.svelte} (89%) rename apps/dashboard/src/lib/features/{program/components/create-program-modal.svelte => cohort/components/create-cohort-modal.svelte} (59%) rename apps/dashboard/src/lib/features/{program => cohort}/components/goals/goal-form-modal.svelte (75%) rename apps/dashboard/src/lib/features/{program => cohort}/components/goals/goals-overview-tiles.svelte (69%) rename apps/dashboard/src/lib/features/{program => cohort}/components/goals/goals-section.svelte (67%) rename apps/dashboard/src/lib/features/{program => cohort}/components/goals/my-goals-widget.svelte (67%) rename apps/dashboard/src/lib/features/{program => cohort}/components/invite-members-modal.svelte (89%) rename apps/dashboard/src/lib/features/{program => cohort}/components/list.svelte (62%) rename apps/dashboard/src/lib/features/{program => cohort}/components/newsfeed/card/card.svelte (86%) rename apps/dashboard/src/lib/features/{program => cohort}/components/newsfeed/card/comments.svelte (81%) rename apps/dashboard/src/lib/features/{program => cohort}/components/newsfeed/card/delete-confirmation.svelte (77%) rename apps/dashboard/src/lib/features/{program => cohort}/components/newsfeed/card/header.svelte (79%) rename apps/dashboard/src/lib/features/{program => cohort}/components/newsfeed/index.ts (100%) rename apps/dashboard/src/lib/features/{program => cohort}/components/newsfeed/new-feed-modal.svelte (58%) rename apps/dashboard/src/lib/features/{program/components/sidebar/program-sidebar-navigation.svelte => cohort/components/sidebar/cohort-sidebar-navigation.svelte} (73%) rename apps/dashboard/src/lib/features/{program/components/sidebar/program-sidebar.svelte => cohort/components/sidebar/cohort-sidebar.svelte} (89%) create mode 100644 apps/dashboard/src/lib/features/cohort/components/sidebar/index.ts rename apps/dashboard/src/lib/features/{program/pages/programs.svelte => cohort/pages/cohorts.svelte} (75%) create mode 100644 apps/dashboard/src/lib/features/cohort/pages/index.ts create mode 100644 apps/dashboard/src/lib/features/cohort/utils/constants.ts create mode 100644 apps/dashboard/src/lib/features/cohort/utils/error.ts create mode 100644 apps/dashboard/src/lib/features/cohort/utils/types.ts delete mode 100644 apps/dashboard/src/lib/features/program/api/goal.svelte.ts delete mode 100644 apps/dashboard/src/lib/features/program/api/index.ts delete mode 100644 apps/dashboard/src/lib/features/program/api/program.svelte.ts delete mode 100644 apps/dashboard/src/lib/features/program/components/sidebar/index.ts delete mode 100644 apps/dashboard/src/lib/features/program/pages/index.ts delete mode 100644 apps/dashboard/src/lib/features/program/utils/constants.ts delete mode 100644 apps/dashboard/src/lib/features/program/utils/error.ts delete mode 100644 apps/dashboard/src/lib/features/program/utils/types.ts rename apps/dashboard/src/routes/(app)/{programs => cohorts}/[id]/+layout.server.ts (71%) rename apps/dashboard/src/routes/(app)/{programs => cohorts}/[id]/+layout.svelte (53%) rename apps/dashboard/src/routes/(app)/{programs => cohorts}/[id]/courses/+page.svelte (87%) rename apps/dashboard/src/routes/(app)/{programs => cohorts}/[id]/newsfeed/+page.svelte (67%) rename apps/dashboard/src/routes/(app)/{programs => cohorts}/[id]/people/+page.svelte (90%) rename apps/dashboard/src/routes/(app)/{programs => cohorts}/[id]/settings/+page.svelte (65%) create mode 100644 apps/dashboard/src/routes/(app)/lms/cohorts/+page.svelte delete mode 100644 apps/dashboard/src/routes/(app)/lms/programs/+page.svelte create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/cohorts/+page.server.ts create mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/cohorts/+page.svelte delete mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/programs/+page.server.ts delete mode 100644 apps/dashboard/src/routes/(app)/org/[slug]/programs/+page.svelte create mode 100644 apps/docs/content/docs/cohorts.mdx delete mode 100644 apps/docs/content/docs/programs.mdx create mode 100644 packages/db/src/migrations/0002_majestic_masked_marvel.sql create mode 100644 packages/db/src/migrations/meta/0002_snapshot.json create mode 100644 packages/db/src/queries/cohort/cohort.ts rename packages/db/src/queries/{program => cohort}/goal.ts (64%) create mode 100644 packages/db/src/queries/cohort/index.ts delete mode 100644 packages/db/src/queries/program/index.ts delete mode 100644 packages/db/src/queries/program/program.ts create mode 100644 packages/db/src/scripts/migrate-programs-to-cohorts.ts rename packages/email/src/emails/{program-goal-reminder.ts => cohort-goal-reminder.ts} (81%) rename packages/email/src/emails/{student-program-welcome.ts => student-cohort-welcome.ts} (64%) rename packages/utils/src/validation/{program/program.ts => cohort/cohort.ts} (71%) create mode 100644 packages/utils/src/validation/cohort/index.ts delete mode 100644 packages/utils/src/validation/program/index.ts diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index 0f76d05d..16d1b887 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -19,7 +19,7 @@ **What it is.** ClassroomIO is an open‑source **Learning Management System (LMS) for companies** — compliance/employee training, customer education, and partner certification. It supports multi‑organization workspaces, courses/lessons/exercises, -programs (cohorts), certificates, an AI course builder + in‑lesson tutor, a public +cohorts (cohorts), certificates, an AI course builder + in‑lesson tutor, a public REST API, an MCP server, and embeddable course widgets. It can be **self‑hosted** or run as the **cloud** product. (`README.md` → lines 11–50 — feature summary; `README.md` → 84–91 — monorepo apps.) @@ -83,9 +83,9 @@ gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no | 11 | Org invites | Invite org members (EMAIL/LINK) | `apps/api/src/routes/invite/invite.ts`, `schema.ts:1384` | Complete | | 12 | Compliance training | Deadlines, renewals, grace, records | `apps/api/src/routes/course/compliance.ts`, `apps/api/src/routes/internal`, `schema.ts:768` (`courseCompletionRecord`) | Complete | | 13 | Certificates | Issue branded certificates | `packages/certificates/src/render.ts`, `schema.ts:850` (`courseCertificateIssue`) | Complete | -| 14 | Programs (cohorts) | Group courses, goals, members, newsfeed | `apps/api/src/routes/program/program.ts`, `schema.ts:2646` | Complete | +| 14 | Cohorts | Group courses, goals, members, newsfeed | `apps/api/src/routes/cohort/cohort.ts`, `schema.ts` cohort tables | Complete | | 15 | Community Q&A | Per‑course questions/answers | `apps/api/src/routes/community/community.ts`, `schema.ts:1558` | Complete | -| 16 | Course newsfeed | Course/program announcements + comments | `apps/api/src/routes/course/newsfeed.ts`, `schema.ts:1605` | Complete | +| 16 | Course newsfeed | Course/cohort announcements + comments | `apps/api/src/routes/course/newsfeed.ts`, `schema.ts:1605` | Complete | | 17 | Attendance | Track group/live‑class attendance | `apps/api/src/routes/course/attendance.ts`, `schema.ts:321` (`groupAttendance`) | Complete | | 18 | AI Course Assistant | In‑course AI authoring/plan/edit chat | `apps/api/src/routes/agent/*`, `packages/ai-assistant`, `schema.ts:3163` (`aiAgentRun`) | Complete (key‑gated) | | 19 | AI Lesson Tutor | In‑lesson learner AI helper + fair‑use caps | `apps/api/src/routes/course/ai-tutor.ts`, `apps/api/src/routes/organization/ai-tutor.ts`, `schema.ts:3013` (`aiTutorMessageCount`) | Complete (key‑gated) | @@ -237,16 +237,16 @@ gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no - **User flow:** Learner completes course → certificate issued → downloadable/branded. - **Edge cases:** custom certificate IDs (README 21); template selection. -### 3.11 Programs (Cohorts) & Goals +### 3.11 Cohorts & Goals - **Purpose:** group courses into cohorts with goals, members, and progress tracking. -- **How it works:** `apps/api/src/routes/program/program.ts`; tables `program` +- **How it works:** `apps/api/src/routes/cohort/cohort.ts`; tables `cohort` (`schema.ts:2646`), `programCourse` (`2677`), `programMember` (`2704`), `programNewsfeed` +`Comment` (`2739`,`2767`), `programGoal`/`…Assignment` (`2829`,`2878`) with goal‑type/ deadline/status enums (`2803–2828`), status enum `ACTIVE|INACTIVE|ARCHIVED` (`2644`). - Middlewares `program-member.ts`, `program-team-member.ts`. PRD `programs [DONE]`. -- **User flow:** Create program → add courses + members → set goals/deadlines → track - progress → cohort newsfeed. `apps/dashboard/src/routes/(app)/programs/[id]`. -- **Edge cases:** goal deadline kinds; assignment status; archived programs. + Middlewares `cohort-member.ts`, `cohort-team-member.ts`. PRD `cohorts [DONE]`. +- **User flow:** Create cohort → add courses + members → set goals/deadlines → track + progress → cohort newsfeed. `apps/dashboard/src/routes/(app)/cohorts/[id]`. +- **Edge cases:** goal deadline kinds; assignment status; archived cohorts. ### 3.12 Community Q&A & Newsfeed - **Purpose:** per‑course discussion (questions/answers) and announcements. @@ -254,9 +254,9 @@ gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no `middlewares/question-author-or-team` (`ARCHITECHTURE.md:241–252`); tables `communityQuestion`/`communityAnswer` (`schema.ts:1558`,`1522`), `courseNewsfeed`/ `…Comment` (`1605`,`1632`). Comment‑author middlewares `newsfeed-comment-author-or-team.ts`, - `program-newsfeed-comment-author-or-team.ts`. + `cohort-newsfeed-comment-author-or-team.ts`. - **User flow:** Course → Community → ask/answer; team can moderate. Newsfeed posts + - comments at course and program level. + comments at course and cohort level. - **Edge cases:** author‑or‑team authorization (only author or course team can edit/delete). ### 3.13 AI Course Assistant (authoring chat) @@ -439,7 +439,7 @@ per‑request DB lookup** (`apps/api/src/middlewares/org-team-member.ts:5–53`) - `orgMemberMiddleware` — must be org member; `orgTeamMemberMiddleware` — ADMIN|TUTOR; `orgAdminMiddleware` — ADMIN only (`ARCHITECHTURE.md:233–237`). - `courseMemberMiddleware` / `courseTeamMemberMiddleware` — course‑level membership/team. -- `programMemberMiddleware` / `programTeamMemberMiddleware` — program‑level. +- `cohortMemberMiddleware` / `cohortTeamMemberMiddleware` — cohort-level. - `apiKeyMiddleware` / `automationKeyMiddleware` (+ `automationKeyScopesMiddleware`) — machine auth for webhooks/public API; `authOrApiKeyMiddleware`, `authOrAutomationKeyMiddleware`, and `*-or-automation-key`/`*-or-api-key` variants allow @@ -449,7 +449,7 @@ per‑request DB lookup** (`apps/api/src/middlewares/org-team-member.ts:5–53`) (`workspace-not-readonly.ts`). - `signupGuard` — signup rules (§3.2). `rateLimiter` — global rate limit (`app.ts:98`). - Route‑specific author/team guards: `question-author-or-team`, - `newsfeed-comment-author-or-team`, `program-newsfeed-comment-author-or-team`. + `newsfeed-comment-author-or-team`, `cohort-newsfeed-comment-author-or-team`. **Public API scopes:** automation keys carry scopes like `public_api:*`, enforced per route (`apps/api/src/routes/v1/courses.ts:47`); usage categorized `read|write|publish` @@ -586,7 +586,7 @@ Upgrade or Buy tokens (api/polar/subscribe | buy-tokens) 5. **Course templates / cohorts / import variants** — `prd/course-templates`, `prd/course-cohorts`, `prd/course-import` are **todo** (`PRD-TRACKER.md:10,12,13`), yet related schema exists (`exerciseTemplate` `schema.ts:2401`; `courseImportDraft` - `schema.ts:2603`; programs are DONE). Scope of what's actually wired end‑to‑end vs. + `schema.ts:2603`; cohorts are DONE). Scope of what's actually wired end‑to‑end vs. schema‑only should be verified per feature before writing test cases. ⚠️ 6. **Anthropic in AI picker** — code supports Anthropic, but it is "not currently in the picker UI" (`README.md:231`, `.env.example:112`). The picker exposes Gemini + GPT‑4o; diff --git a/README.md b/README.md index 667a8cfb..1cec460a 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,21 @@ ClassroomIO Courses page -ClassroomIO is an open source LMS for companies. Run compliance/employee training, customer education, and partner certification programs — all from one platform. Self-host it on your own infrastructure or use the cloud version. +ClassroomIO is an open source LMS for companies. Run compliance/employee training, customer education, and partner certification cohorts — all from one platform. Self-host it on your own infrastructure or use the cloud version. ### Who It's For | Use Case | What You Get | |---|---| | **Compliance Training** | Deadline tracking, renewals & retake intervals, grace periods, waivers, certificates with custom IDs | -| **Customer Education** | Branded academy portal, programs (cohorts with goals), AI lesson tutor, multilingual content | +| **Customer Education** | Branded academy portal, cohorts (cohorts with goals), AI lesson tutor, multilingual content | | **Partner Training** | Partner workspaces, branded certificates, custom domains, embeddable widgets, multilingual support | ### Key Features **Course & Content** - **Course management** — unlimited courses, lessons, exercises, grading, and certificates -- **Programs** — group courses into cohorts with goals, team management, and progress tracking +- **Cohorts** — group courses into cohorts with goals, team management, and progress tracking - **AI Course Builder** — generate outlines, lesson content, and assignments (Gemini, GPT-4o, Claude) - **AI Lesson Tutor** — in-lesson AI assistant that helps learners as they study diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 76521596..bb648b1a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -31,7 +31,7 @@ import { organizationRouter } from '@api/routes/organization'; import { organizationSsoRouter } from '@api/routes/organization/sso'; import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth'; import { prettyJSON } from 'hono/pretty-json'; -import { programRouter } from '@api/routes/program'; +import { cohortRouter } from '@api/routes/cohort'; import { publicCourseRouter } from '@api/routes/org-site'; import { publicWidgetsRouter } from '@api/routes/widgets'; import rateLimiter from '@api/middlewares/rate-limiter'; @@ -232,7 +232,7 @@ export const app = new Hono() .route('/invite', inviteRouter) .route('/org-site/course', publicCourseRouter) .route('/public-api/v1', v1Router) - .route('/program', programRouter) + .route('/cohort', cohortRouter) .route('/unsplash', unsplashRouter) .route('/widgets', publicWidgetsRouter) .route('/internal', internalRouter) diff --git a/apps/api/src/middlewares/program-member.ts b/apps/api/src/middlewares/cohort-member.ts similarity index 53% rename from apps/api/src/middlewares/program-member.ts rename to apps/api/src/middlewares/cohort-member.ts index 8002f250..99250a19 100644 --- a/apps/api/src/middlewares/program-member.ts +++ b/apps/api/src/middlewares/cohort-member.ts @@ -1,13 +1,13 @@ import { Context, Next } from 'hono'; import { ErrorCodes } from '@api/utils/errors'; -import { isOrgAdminByProgramId, isProgramMember } from '@cio/db/queries/program'; +import { isOrgAdminByCohortId, isCohortMember } from '@cio/db/queries/cohort'; /** - * Middleware to check if the authenticated user is a member of the program - * or an org admin for the program's organization. + * Middleware to check if the authenticated user is a member of the cohort + * or an org admin for the cohort's organization. */ -export const programMemberMiddleware = async (c: Context, next: Next) => { +export const cohortMemberMiddleware = async (c: Context, next: Next) => { try { const user = c.get('user'); if (!user) { @@ -21,21 +21,21 @@ export const programMemberMiddleware = async (c: Context, next: Next) => { ); } - const programId = c.req.param('programId'); - if (!programId) { + const cohortId = c.req.param('cohortId'); + if (!cohortId) { return c.json( { success: false, error: 'Program ID is required', - code: 'PROGRAM_ID_REQUIRED' + code: 'COHORT_ID_REQUIRED' }, 400 ); } const [member, isOrgAdmin] = await Promise.all([ - isProgramMember(programId, user.id), - isOrgAdminByProgramId(programId, user.id) + isCohortMember(cohortId, user.id), + isOrgAdminByCohortId(cohortId, user.id) ]); if (member || isOrgAdmin) { @@ -45,18 +45,18 @@ export const programMemberMiddleware = async (c: Context, next: Next) => { return c.json( { success: false, - error: 'You must be a member of this program to perform this action', - code: ErrorCodes.PROGRAM_FORBIDDEN + error: 'You must be a member of this cohort to perform this action', + code: ErrorCodes.COHORT_FORBIDDEN }, 403 ); } catch (error) { - console.error('Error in programMemberMiddleware:', error); + console.error('Error in cohortMemberMiddleware:', error); return c.json( { success: false, - error: 'Failed to verify program membership', - code: ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED + error: 'Failed to verify cohort membership', + code: ErrorCodes.COHORT_MEMBER_CHECK_FAILED }, 500 ); diff --git a/apps/api/src/middlewares/program-newsfeed-comment-author-or-team.ts b/apps/api/src/middlewares/cohort-newsfeed-comment-author-or-team.ts similarity index 62% rename from apps/api/src/middlewares/program-newsfeed-comment-author-or-team.ts rename to apps/api/src/middlewares/cohort-newsfeed-comment-author-or-team.ts index 0465a2e1..8d171647 100644 --- a/apps/api/src/middlewares/program-newsfeed-comment-author-or-team.ts +++ b/apps/api/src/middlewares/cohort-newsfeed-comment-author-or-team.ts @@ -1,15 +1,15 @@ import { Context, Next } from 'hono'; import { ErrorCodes } from '@api/utils/errors'; import { - getProgramMemberByProfileId, - getProgramMemberRole, - getProgramNewsfeedById, - getProgramNewsfeedCommentById, - isOrgAdminByProgramId -} from '@cio/db/queries/program'; + getCohortMemberByProfileId, + getCohortMemberRole, + getCohortNewsfeedById, + getCohortNewsfeedCommentById, + isOrgAdminByCohortId +} from '@cio/db/queries/cohort'; import { ROLE } from '@cio/utils/constants'; -export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => { +export const cohortNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => { try { const user = c.get('user'); if (!user) { @@ -35,44 +35,44 @@ export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, n ); } - const comment = await getProgramNewsfeedCommentById(Number(commentId)); - if (!comment?.programNewsfeedId) { + const comment = await getCohortNewsfeedCommentById(Number(commentId)); + if (!comment?.cohortNewsfeedId) { return c.json( { success: false, error: 'Comment not found', - code: ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND + code: ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND }, 404 ); } - const feed = await getProgramNewsfeedById(comment.programNewsfeedId); - if (!feed?.programId) { + const feed = await getCohortNewsfeedById(comment.cohortNewsfeedId); + if (!feed?.cohortId) { return c.json( { success: false, - error: 'Program newsfeed item not found', - code: ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND + error: 'Cohort newsfeed item not found', + code: ErrorCodes.COHORT_NEWSFEED_NOT_FOUND }, 404 ); } - const member = await getProgramMemberByProfileId(feed.programId, user.id); + const member = await getCohortMemberByProfileId(feed.cohortId, user.id); const isAuthor = comment.authorId === member?.id; if (isAuthor) { await next(); return; } - const isOrgAdmin = await isOrgAdminByProgramId(feed.programId, user.id); + const isOrgAdmin = await isOrgAdminByCohortId(feed.cohortId, user.id); if (isOrgAdmin) { await next(); return; } - const roleId = await getProgramMemberRole(feed.programId, user.id); + const roleId = await getCohortMemberRole(feed.cohortId, user.id); const isTeamMember = roleId === ROLE.ADMIN || roleId === ROLE.TUTOR; if (isTeamMember) { await next(); @@ -88,7 +88,7 @@ export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, n 403 ); } catch (error) { - console.error('Error in programNewsfeedCommentAuthorOrTeamMiddleware:', error); + console.error('Error in cohortNewsfeedCommentAuthorOrTeamMiddleware:', error); return c.json( { success: false, diff --git a/apps/api/src/middlewares/program-team-member.ts b/apps/api/src/middlewares/cohort-team-member.ts similarity index 64% rename from apps/api/src/middlewares/program-team-member.ts rename to apps/api/src/middlewares/cohort-team-member.ts index 6e88af5e..c885e665 100644 --- a/apps/api/src/middlewares/program-team-member.ts +++ b/apps/api/src/middlewares/cohort-team-member.ts @@ -1,14 +1,14 @@ import { Context, Next } from 'hono'; import { ErrorCodes } from '@api/utils/errors'; -import { getProgramMemberRole, isOrgAdminByProgramId } from '@cio/db/queries/program'; +import { getCohortMemberRole, isOrgAdminByCohortId } from '@cio/db/queries/cohort'; import { ROLE } from '@cio/utils/constants'; /** - * Middleware to check if the authenticated user is a program teacher or org admin. + * Middleware to check if the authenticated user is a cohort teacher or org admin. * Mirrors the course-team-member pattern for program routes. */ -export const programTeamMemberMiddleware = async (c: Context, next: Next) => { +export const cohortTeamMemberMiddleware = async (c: Context, next: Next) => { try { const user = c.get('user'); if (!user) { @@ -22,21 +22,21 @@ export const programTeamMemberMiddleware = async (c: Context, next: Next) => { ); } - const programId = c.req.param('programId'); - if (!programId) { + const cohortId = c.req.param('cohortId'); + if (!cohortId) { return c.json( { success: false, error: 'Program ID is required', - code: 'PROGRAM_ID_REQUIRED' + code: 'COHORT_ID_REQUIRED' }, 400 ); } const [roleId, isOrgAdmin] = await Promise.all([ - getProgramMemberRole(programId, user.id), - isOrgAdminByProgramId(programId, user.id) + getCohortMemberRole(cohortId, user.id), + isOrgAdminByCohortId(cohortId, user.id) ]); if (roleId === ROLE.TUTOR || roleId === ROLE.ADMIN || isOrgAdmin) { @@ -52,12 +52,12 @@ export const programTeamMemberMiddleware = async (c: Context, next: Next) => { 403 ); } catch (error) { - console.error('Error in programTeamMemberMiddleware:', error); + console.error('Error in cohortTeamMemberMiddleware:', error); return c.json( { success: false, error: 'Failed to verify program team membership', - code: 'PROGRAM_TEAM_MEMBER_CHECK_FAILED' + code: 'COHORT_TEAM_MEMBER_CHECK_FAILED' }, 500 ); diff --git a/apps/api/src/routes/cohort/cohort.ts b/apps/api/src/routes/cohort/cohort.ts new file mode 100644 index 00000000..e5204b64 --- /dev/null +++ b/apps/api/src/routes/cohort/cohort.ts @@ -0,0 +1,730 @@ +import * as z from 'zod'; + +import { + ZAddCourseToCohort, + ZAddCohortMembers, + ZAssignExistingStudentsToCohort, + ZCreateCohort, + ZCreateCohortGoal, + ZCreateCohortNewsfeed, + ZCreateCohortNewsfeedComment, + ZInviteStudentsToCohort, + ZUpdateCohort, + ZUpdateCohortGoal, + ZUpdateCohortMember, + ZUpdateCohortNewsfeed, + ZUpdateCohortReaction +} from '@cio/utils/validation/cohort'; +import { + addCourseToCohortService, + addCohortMembers, + createCohort, + createCohortNewsfeedCommentService, + createCohortNewsfeedService, + deleteCohort, + removeCohortMemberService, + deleteCohortNewsfeedCommentService, + deleteCohortNewsfeedService, + getEnrolledCohorts, + getCohort, + listOrgCohorts, + listCohortCourses, + listCohortMembers, + listCohortNewsfeed, + listCohortNewsfeedComments, + removeCourseFromCohortService, + updateCohort, + updateCohortMemberService, + updateCohortNewsfeedReactionService, + updateCohortNewsfeedService +} from '@api/services/cohort/cohort'; +import { assignExistingStudentsToCohort, inviteStudentsToCohort } from '@api/services/cohort/invite'; +import { + archiveGoal, + createGoal, + evaluateGoal, + evaluateCohortGoals, + getGoal, + getMyGoals, + getOrgGoalsOverview, + listGoals, + removeGoal, + updateGoal +} from '@api/services/cohort/goal'; + +import { Hono } from '@api/utils/hono'; +import { authMiddleware } from '@api/middlewares/auth'; +import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member'; +import { cohortMemberMiddleware } from '@api/middlewares/cohort-member'; +import { cohortTeamMemberMiddleware } from '@api/middlewares/cohort-team-member'; +import { cohortNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/cohort-newsfeed-comment-author-or-team'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; + +const ZCohortParam = z.object({ cohortId: z.string().uuid() }); +const ZMemberParam = z.object({ cohortId: z.string().uuid(), memberId: z.string().uuid() }); +const ZCourseParam = z.object({ cohortId: z.string().uuid(), courseId: z.string().uuid() }); +const ZFeedParam = z.object({ cohortId: z.string().uuid(), feedId: z.string().uuid() }); +const ZCommentParam = z.object({ + cohortId: z.string().uuid(), + feedId: z.string().uuid(), + commentId: z.coerce.number().int() +}); +const ZGoalParam = z.object({ cohortId: z.string().uuid(), goalId: z.string().uuid() }); +const ZListQuery = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(50).default(10) +}); +const ZOrgQuery = z.object({ organizationId: z.string().uuid() }); + +export const cohortRouter = new Hono() + // ── Enrolled Cohorts (LMS) ─── must be before /:cohortId ─────────────── + + /** + * GET /cohort/enrolled + * Get programs the current user is enrolled in + */ + .get('/enrolled', authMiddleware, async (c) => { + try { + const user = c.get('user')!; + const programs = await getEnrolledCohorts(user.id); + return c.json({ success: true, data: programs }, 200); + } catch (error) { + return handleError(c, error, 'Failed to get enrolled cohorts'); + } + }) + + // ── Programs ────────────────────────────────────────────────────────────── + + /** + * GET /cohort?organizationId=... + * List all programs for an org + */ + .get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => { + try { + const { organizationId } = c.req.valid('query'); + const programs = await listOrgCohorts(organizationId); + return c.json({ success: true, data: programs }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list cohorts'); + } + }) + + /** + * POST /cohort + * Create a new program + */ + .post( + '/', + authMiddleware, + zValidator('json', ZCreateCohort.extend({ organizationId: z.string().uuid() })), + async (c) => { + try { + const user = c.get('user')!; + const { organizationId, ...data } = c.req.valid('json'); + const cohort = await createCohort(organizationId, user.id, data); + return c.json({ success: true, data: cohort }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create cohort'); + } + } + ) + + /** + * GET /cohort/:cohortId + * Get a cohort by ID + */ + .get('/:cohortId', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const cohort = await getCohort(cohortId); + return c.json({ success: true, data: cohort }, 200); + } catch (error) { + return handleError(c, error, 'Failed to get cohort'); + } + }) + + /** + * PUT /cohort/:cohortId + * Update a cohort + */ + .put( + '/:cohortId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('json', ZUpdateCohort), + async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const data = c.req.valid('json'); + const cohort = await updateCohort(cohortId, data); + return c.json({ success: true, data: cohort }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update cohort'); + } + } + ) + + /** + * DELETE /cohort/:cohortId + * Delete a cohort + */ + .delete('/:cohortId', authMiddleware, cohortTeamMemberMiddleware, zValidator('param', ZCohortParam), async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const cohort = await deleteCohort(cohortId); + return c.json({ success: true, data: cohort }, 200); + } catch (error) { + return handleError(c, error, 'Failed to delete cohort'); + } + }) + + // ── Members ─────────────────────────────────────────────────────────────── + + /** + * GET /cohort/:cohortId/members + * List cohort members + */ + .get('/:cohortId/members', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const members = await listCohortMembers(cohortId); + return c.json({ success: true, data: members }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list cohort members'); + } + }) + + /** + * POST /cohort/:cohortId/members + * Add members to a cohort + */ + .post( + '/:cohortId/members', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('json', ZAddCohortMembers), + async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const data = c.req.valid('json'); + const result = await addCohortMembers(cohortId, data); + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to add cohort members'); + } + } + ) + + /** + * PUT /cohort/:cohortId/members/:memberId + * Update a cohort member's role + */ + .put( + '/:cohortId/members/:memberId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZMemberParam), + zValidator('json', ZUpdateCohortMember), + async (c) => { + try { + const { cohortId, memberId } = c.req.valid('param'); + const data = c.req.valid('json'); + const member = await updateCohortMemberService(cohortId, memberId, data); + return c.json({ success: true, data: member }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update cohort member'); + } + } + ) + + /** + * DELETE /cohort/:cohortId/members/:memberId + * Remove a member from a cohort + */ + .delete( + '/:cohortId/members/:memberId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZMemberParam), + async (c) => { + try { + const { cohortId, memberId } = c.req.valid('param'); + const member = await removeCohortMemberService(cohortId, memberId); + return c.json({ success: true, data: member }, 200); + } catch (error) { + return handleError(c, error, 'Failed to remove program member'); + } + } + ) + + // ── Invite ──────────────────────────────────────────────────────────────── + + /** + * POST /cohort/:cohortId/invite + * Invite new students to a cohort by CSV emails. Issues org invites + * pre-tagged with this cohort so accept auto-enrolls them. + * Gated by cohort-team-member middleware — Cohort ADMINs/TUTORs can + * invite into their own program without org-admin rights. + */ + .post( + '/:cohortId/invite', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('json', ZInviteStudentsToCohort), + async (c) => { + try { + const user = c.get('user')!; + const { cohortId } = c.req.valid('param'); + const data = c.req.valid('json'); + const result = await inviteStudentsToCohort(cohortId, data, user.id); + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to invite students to program'); + } + } + ) + + /** + * POST /cohort/:cohortId/invite/assign + * Assign existing org-audience student profiles to a cohort. + */ + .post( + '/:cohortId/invite/assign', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('json', ZAssignExistingStudentsToCohort), + async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const data = c.req.valid('json'); + const result = await assignExistingStudentsToCohort(cohortId, data); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to assign students to program'); + } + } + ) + + // ── Courses ─────────────────────────────────────────────────────────────── + + /** + * GET /cohort/:cohortId/courses + * List courses in a cohort + */ + .get('/:cohortId/courses', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => { + try { + const user = c.get('user')!; + const { cohortId } = c.req.valid('param'); + const courses = await listCohortCourses(cohortId, user.id); + return c.json({ success: true, data: courses }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list cohort courses'); + } + }) + + /** + * POST /cohort/:cohortId/courses + * Add a course to a cohort + */ + .post( + '/:cohortId/courses', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('json', ZAddCourseToCohort), + async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const data = c.req.valid('json'); + const result = await addCourseToCohortService(cohortId, data); + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to add course to program'); + } + } + ) + + /** + * DELETE /cohort/:cohortId/courses/:courseId + * Remove a course from a cohort + */ + .delete( + '/:cohortId/courses/:courseId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCourseParam), + async (c) => { + try { + const { cohortId, courseId } = c.req.valid('param'); + const result = await removeCourseFromCohortService(cohortId, courseId); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to remove course from program'); + } + } + ) + + // ── Newsfeed ────────────────────────────────────────────────────────────── + + /** + * GET /cohort/:cohortId/newsfeed + * List newsfeed for a cohort (paginated) + */ + .get( + '/:cohortId/newsfeed', + authMiddleware, + cohortMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('query', ZListQuery), + async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const { cursor, limit } = c.req.valid('query'); + const result = await listCohortNewsfeed(cohortId, { cursor, limit }); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list cohort newsfeed'); + } + } + ) + + /** + * POST /cohort/:cohortId/newsfeed + * Create a newsfeed post + */ + .post( + '/:cohortId/newsfeed', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('json', ZCreateCohortNewsfeed), + async (c) => { + try { + const user = c.get('user')!; + const { cohortId } = c.req.valid('param'); + const data = c.req.valid('json'); + const feed = await createCohortNewsfeedService(cohortId, user.id, data); + return c.json({ success: true, data: feed }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create cohort newsfeed post'); + } + } + ) + + /** + * PUT /cohort/:cohortId/newsfeed/:feedId + * Update a newsfeed post + */ + .put( + '/:cohortId/newsfeed/:feedId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZFeedParam), + zValidator('json', ZUpdateCohortNewsfeed), + async (c) => { + try { + const { feedId } = c.req.valid('param'); + const data = c.req.valid('json'); + const feed = await updateCohortNewsfeedService(feedId, data); + return c.json({ success: true, data: feed }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update cohort newsfeed post'); + } + } + ) + + /** + * PUT /cohort/:cohortId/newsfeed/:feedId/react + * Update reaction on a newsfeed post + */ + .put( + '/:cohortId/newsfeed/:feedId/react', + authMiddleware, + cohortMemberMiddleware, + zValidator('param', ZFeedParam), + zValidator('json', ZUpdateCohortReaction), + async (c) => { + try { + const { feedId } = c.req.valid('param'); + const data = c.req.valid('json'); + const feed = await updateCohortNewsfeedReactionService(feedId, data); + return c.json({ success: true, data: feed }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update cohort newsfeed reaction'); + } + } + ) + + /** + * DELETE /cohort/:cohortId/newsfeed/:feedId + * Delete a newsfeed post + */ + .delete( + '/:cohortId/newsfeed/:feedId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZFeedParam), + async (c) => { + try { + const { feedId } = c.req.valid('param'); + const feed = await deleteCohortNewsfeedService(feedId); + return c.json({ success: true, data: feed }, 200); + } catch (error) { + return handleError(c, error, 'Failed to delete cohort newsfeed post'); + } + } + ) + + /** + * GET /cohort/:cohortId/newsfeed/:feedId/comments + * List comments on a newsfeed post + */ + .get( + '/:cohortId/newsfeed/:feedId/comments', + authMiddleware, + cohortMemberMiddleware, + zValidator('param', ZFeedParam), + async (c) => { + try { + const { feedId } = c.req.valid('param'); + const comments = await listCohortNewsfeedComments(feedId); + return c.json({ success: true, data: comments }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list cohort newsfeed comments'); + } + } + ) + + /** + * POST /cohort/:cohortId/newsfeed/:feedId/comment + * Create a comment on a newsfeed post + */ + .post( + '/:cohortId/newsfeed/:feedId/comment', + authMiddleware, + cohortMemberMiddleware, + zValidator('param', ZFeedParam), + zValidator('json', ZCreateCohortNewsfeedComment), + async (c) => { + try { + const user = c.get('user')!; + const { feedId } = c.req.valid('param'); + const data = c.req.valid('json'); + const comment = await createCohortNewsfeedCommentService(feedId, user.id, data); + return c.json({ success: true, data: comment }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create cohort newsfeed comment'); + } + } + ) + + /** + * DELETE /cohort/:cohortId/newsfeed/:feedId/comment/:commentId + * Delete a comment on a newsfeed post + */ + .delete( + '/:cohortId/newsfeed/:feedId/comment/:commentId', + authMiddleware, + cohortNewsfeedCommentAuthorOrTeamMiddleware, + zValidator('param', ZCommentParam), + async (c) => { + try { + const { commentId } = c.req.valid('param'); + const comment = await deleteCohortNewsfeedCommentService(commentId); + return c.json({ success: true, data: comment }, 200); + } catch (error) { + return handleError(c, error, 'Failed to delete cohort newsfeed comment'); + } + } + ) + + // ── Goals ───────────────────────────────────────────────────────────────── + + /** + * GET /cohort/my/goals + * Goal assignments for the current authenticated user across all their programs. + * Used by the LMS "Your Goals" widget. + */ + .get('/my/goals', authMiddleware, async (c) => { + try { + const user = c.get('user')!; + const goals = await getMyGoals(user.id); + return c.json({ success: true, data: goals }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load goals'); + } + }) + + /** + * GET /cohort/goals/overview?organizationId=... + * Cross-cohort goal roll-up for the org owner. One entry per active goal + * with per-status learner counts, scoped to the org via header + query. + */ + .get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => { + try { + const { organizationId } = c.req.valid('query'); + const overview = await getOrgGoalsOverview(organizationId); + return c.json({ success: true, data: overview }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load goals overview'); + } + }) + + /** + * GET /cohort/:cohortId/goals + */ + .get('/:cohortId/goals', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const goals = await listGoals(cohortId); + return c.json({ success: true, data: goals }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list cohort goals'); + } + }) + + /** + * POST /cohort/:cohortId/goals + */ + .post( + '/:cohortId/goals', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + zValidator('json', ZCreateCohortGoal), + async (c) => { + try { + const user = c.get('user')!; + const { cohortId } = c.req.valid('param'); + const data = c.req.valid('json'); + const goal = await createGoal(cohortId, user.id, data); + return c.json({ success: true, data: goal }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create cohort goal'); + } + } + ) + + /** + * GET /cohort/:cohortId/goals/:goalId + */ + .get( + '/:cohortId/goals/:goalId', + authMiddleware, + cohortMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const goal = await getGoal(goalId); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to get cohort goal'); + } + } + ) + + /** + * PUT /cohort/:cohortId/goals/:goalId + */ + .put( + '/:cohortId/goals/:goalId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZGoalParam), + zValidator('json', ZUpdateCohortGoal), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const data = c.req.valid('json'); + const goal = await updateGoal(goalId, data); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update cohort goal'); + } + } + ) + + /** + * DELETE /cohort/:cohortId/goals/:goalId + * Hard delete (vs. archive). Use PUT with status=archived to preserve history. + */ + .delete( + '/:cohortId/goals/:goalId', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const goal = await removeGoal(goalId); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to delete cohort goal'); + } + } + ) + + /** + * POST /cohort/:cohortId/goals/:goalId/archive + */ + .post( + '/:cohortId/goals/:goalId/archive', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const goal = await archiveGoal(goalId); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to archive cohort goal'); + } + } + ) + + /** + * POST /cohort/:cohortId/goals/:goalId/evaluate + * Force a re-evaluation of one goal's per-learner statuses. + */ + .post( + '/:cohortId/goals/:goalId/evaluate', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const result = await evaluateGoal(goalId); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to evaluate cohort goal'); + } + } + ) + + /** + * POST /cohort/:cohortId/goals/evaluate-all + * Force a re-evaluation of every active goal in the cohort. + */ + .post( + '/:cohortId/goals/evaluate-all', + authMiddleware, + cohortTeamMemberMiddleware, + zValidator('param', ZCohortParam), + async (c) => { + try { + const { cohortId } = c.req.valid('param'); + const result = await evaluateCohortGoals(cohortId); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to evaluate cohort goals'); + } + } + ); diff --git a/apps/api/src/routes/cohort/index.ts b/apps/api/src/routes/cohort/index.ts new file mode 100644 index 00000000..a7d19404 --- /dev/null +++ b/apps/api/src/routes/cohort/index.ts @@ -0,0 +1 @@ +export { cohortRouter } from './cohort'; diff --git a/apps/api/src/routes/internal/compliance.ts b/apps/api/src/routes/internal/compliance.ts index 59d42a41..bf62bfdd 100644 --- a/apps/api/src/routes/internal/compliance.ts +++ b/apps/api/src/routes/internal/compliance.ts @@ -1,5 +1,5 @@ import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance'; -import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal'; +import { runCohortGoalEvaluationSweep, runCohortGoalReminderScan } from '@api/services/cohort/goal'; import { Hono } from '@api/utils/hono'; import { apiKeyMiddleware } from '@api/middlewares/api-key'; @@ -47,30 +47,30 @@ export const internalComplianceRouter = new Hono() } }) /** - * POST /internal/compliance/evaluate-program-goals - * Daily sweep: re-evaluate every active program goal and refresh per-learner - * `program_goal_assignment` rows. + * POST /internal/compliance/evaluate-cohort-goals + * Daily sweep: re-evaluate every active cohort goal and refresh per-learner + * `cohort_goal_assignment` rows. */ - .post('/evaluate-program-goals', apiKeyMiddleware, async (c) => { + .post('/evaluate-cohort-goals', apiKeyMiddleware, async (c) => { try { - const data = await runProgramGoalEvaluationSweep(); + const data = await runCohortGoalEvaluationSweep(); return c.json({ success: true, data }, 200); } catch (error) { - return handleError(c, error, 'Failed to run program goal evaluation sweep'); + return handleError(c, error, 'Failed to run cohort goal evaluation sweep'); } }) /** - * POST /internal/compliance/send-program-goal-reminders - * Sends learner reminder emails for upcoming/overdue program goals using + * POST /internal/compliance/send-cohort-goal-reminders + * Sends learner reminder emails for upcoming/overdue cohort goals using * each goal's `reminderDaysBefore` cadence. */ - .post('/send-program-goal-reminders', apiKeyMiddleware, async (c) => { + .post('/send-cohort-goal-reminders', apiKeyMiddleware, async (c) => { try { - const data = await runProgramGoalReminderScan(); + const data = await runCohortGoalReminderScan(); return c.json({ success: true, data }, 200); } catch (error) { - return handleError(c, error, 'Failed to run program goal reminder scan'); + return handleError(c, error, 'Failed to run cohort goal reminder scan'); } }); diff --git a/apps/api/src/routes/program/index.ts b/apps/api/src/routes/program/index.ts deleted file mode 100644 index 3e5fdab7..00000000 --- a/apps/api/src/routes/program/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { programRouter } from './program'; diff --git a/apps/api/src/routes/program/program.ts b/apps/api/src/routes/program/program.ts deleted file mode 100644 index f3eaa062..00000000 --- a/apps/api/src/routes/program/program.ts +++ /dev/null @@ -1,742 +0,0 @@ -import * as z from 'zod'; - -import { - ZAddCourseToProgram, - ZAddProgramMembers, - ZAssignExistingStudentsToProgram, - ZCreateProgram, - ZCreateProgramGoal, - ZCreateProgramNewsfeed, - ZCreateProgramNewsfeedComment, - ZInviteStudentsToProgram, - ZUpdateProgram, - ZUpdateProgramGoal, - ZUpdateProgramMember, - ZUpdateProgramNewsfeed, - ZUpdateProgramReaction -} from '@cio/utils/validation/program'; -import { - addCourseToProgramService, - addProgramMembers, - createProgram, - createProgramNewsfeedCommentService, - createProgramNewsfeedService, - deleteProgram, - removeProgramMemberService, - deleteProgramNewsfeedCommentService, - deleteProgramNewsfeedService, - getEnrolledPrograms, - getProgram, - listOrgPrograms, - listProgramCourses, - listProgramMembers, - listProgramNewsfeed, - listProgramNewsfeedComments, - removeCourseFromProgramService, - updateProgram, - updateProgramMemberService, - updateProgramNewsfeedReactionService, - updateProgramNewsfeedService -} from '@api/services/program/program'; -import { assignExistingStudentsToProgram, inviteStudentsToProgram } from '@api/services/program/invite'; -import { - archiveGoal, - createGoal, - evaluateGoal, - evaluateProgramGoals, - getGoal, - getMyGoals, - getOrgGoalsOverview, - listGoals, - removeGoal, - updateGoal -} from '@api/services/program/goal'; - -import { Hono } from '@api/utils/hono'; -import { authMiddleware } from '@api/middlewares/auth'; -import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member'; -import { programMemberMiddleware } from '@api/middlewares/program-member'; -import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member'; -import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team'; -import { handleError } from '@api/utils/errors'; -import { zValidator } from '@hono/zod-validator'; - -const ZProgramParam = z.object({ programId: z.string().uuid() }); -const ZMemberParam = z.object({ programId: z.string().uuid(), memberId: z.string().uuid() }); -const ZCourseParam = z.object({ programId: z.string().uuid(), courseId: z.string().uuid() }); -const ZFeedParam = z.object({ programId: z.string().uuid(), feedId: z.string().uuid() }); -const ZCommentParam = z.object({ - programId: z.string().uuid(), - feedId: z.string().uuid(), - commentId: z.coerce.number().int() -}); -const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() }); -const ZListQuery = z.object({ - cursor: z.string().optional(), - limit: z.coerce.number().int().min(1).max(50).default(10) -}); -const ZOrgQuery = z.object({ organizationId: z.string().uuid() }); - -export const programRouter = new Hono() - // ── Enrolled Programs (LMS) ─── must be before /:programId ─────────────── - - /** - * GET /program/enrolled - * Get programs the current user is enrolled in - */ - .get('/enrolled', authMiddleware, async (c) => { - try { - const user = c.get('user')!; - const programs = await getEnrolledPrograms(user.id); - return c.json({ success: true, data: programs }, 200); - } catch (error) { - return handleError(c, error, 'Failed to get enrolled programs'); - } - }) - - // ── Programs ────────────────────────────────────────────────────────────── - - /** - * GET /program?organizationId=... - * List all programs for an org - */ - .get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => { - try { - const { organizationId } = c.req.valid('query'); - const programs = await listOrgPrograms(organizationId); - return c.json({ success: true, data: programs }, 200); - } catch (error) { - return handleError(c, error, 'Failed to list programs'); - } - }) - - /** - * POST /program - * Create a new program - */ - .post( - '/', - authMiddleware, - zValidator('json', ZCreateProgram.extend({ organizationId: z.string().uuid() })), - async (c) => { - try { - const user = c.get('user')!; - const { organizationId, ...data } = c.req.valid('json'); - const program = await createProgram(organizationId, user.id, data); - return c.json({ success: true, data: program }, 201); - } catch (error) { - return handleError(c, error, 'Failed to create program'); - } - } - ) - - /** - * GET /program/:programId - * Get a program by ID - */ - .get('/:programId', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => { - try { - const { programId } = c.req.valid('param'); - const program = await getProgram(programId); - return c.json({ success: true, data: program }, 200); - } catch (error) { - return handleError(c, error, 'Failed to get program'); - } - }) - - /** - * PUT /program/:programId - * Update a program - */ - .put( - '/:programId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('json', ZUpdateProgram), - async (c) => { - try { - const { programId } = c.req.valid('param'); - const data = c.req.valid('json'); - const program = await updateProgram(programId, data); - return c.json({ success: true, data: program }, 200); - } catch (error) { - return handleError(c, error, 'Failed to update program'); - } - } - ) - - /** - * DELETE /program/:programId - * Delete a program - */ - .delete('/:programId', authMiddleware, programTeamMemberMiddleware, zValidator('param', ZProgramParam), async (c) => { - try { - const { programId } = c.req.valid('param'); - const program = await deleteProgram(programId); - return c.json({ success: true, data: program }, 200); - } catch (error) { - return handleError(c, error, 'Failed to delete program'); - } - }) - - // ── Members ─────────────────────────────────────────────────────────────── - - /** - * GET /program/:programId/members - * List program members - */ - .get( - '/:programId/members', - authMiddleware, - programMemberMiddleware, - zValidator('param', ZProgramParam), - async (c) => { - try { - const { programId } = c.req.valid('param'); - const members = await listProgramMembers(programId); - return c.json({ success: true, data: members }, 200); - } catch (error) { - return handleError(c, error, 'Failed to list program members'); - } - } - ) - - /** - * POST /program/:programId/members - * Add members to a program - */ - .post( - '/:programId/members', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('json', ZAddProgramMembers), - async (c) => { - try { - const { programId } = c.req.valid('param'); - const data = c.req.valid('json'); - const result = await addProgramMembers(programId, data); - return c.json({ success: true, data: result }, 201); - } catch (error) { - return handleError(c, error, 'Failed to add program members'); - } - } - ) - - /** - * PUT /program/:programId/members/:memberId - * Update a program member's role - */ - .put( - '/:programId/members/:memberId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZMemberParam), - zValidator('json', ZUpdateProgramMember), - async (c) => { - try { - const { programId, memberId } = c.req.valid('param'); - const data = c.req.valid('json'); - const member = await updateProgramMemberService(programId, memberId, data); - return c.json({ success: true, data: member }, 200); - } catch (error) { - return handleError(c, error, 'Failed to update program member'); - } - } - ) - - /** - * DELETE /program/:programId/members/:memberId - * Remove a member from a program - */ - .delete( - '/:programId/members/:memberId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZMemberParam), - async (c) => { - try { - const { programId, memberId } = c.req.valid('param'); - const member = await removeProgramMemberService(programId, memberId); - return c.json({ success: true, data: member }, 200); - } catch (error) { - return handleError(c, error, 'Failed to remove program member'); - } - } - ) - - // ── Invite ──────────────────────────────────────────────────────────────── - - /** - * POST /program/:programId/invite - * Invite new students to a program by CSV emails. Issues org invites - * pre-tagged with this program so accept auto-enrolls them. - * Gated by program-team-member middleware — Program ADMINs/TUTORs can - * invite into their own program without org-admin rights. - */ - .post( - '/:programId/invite', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('json', ZInviteStudentsToProgram), - async (c) => { - try { - const user = c.get('user')!; - const { programId } = c.req.valid('param'); - const data = c.req.valid('json'); - const result = await inviteStudentsToProgram(programId, data, user.id); - return c.json({ success: true, data: result }, 201); - } catch (error) { - return handleError(c, error, 'Failed to invite students to program'); - } - } - ) - - /** - * POST /program/:programId/invite/assign - * Assign existing org-audience student profiles to a program. - */ - .post( - '/:programId/invite/assign', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('json', ZAssignExistingStudentsToProgram), - async (c) => { - try { - const { programId } = c.req.valid('param'); - const data = c.req.valid('json'); - const result = await assignExistingStudentsToProgram(programId, data); - return c.json({ success: true, data: result }, 200); - } catch (error) { - return handleError(c, error, 'Failed to assign students to program'); - } - } - ) - - // ── Courses ─────────────────────────────────────────────────────────────── - - /** - * GET /program/:programId/courses - * List courses in a program - */ - .get( - '/:programId/courses', - authMiddleware, - programMemberMiddleware, - zValidator('param', ZProgramParam), - async (c) => { - try { - const user = c.get('user')!; - const { programId } = c.req.valid('param'); - const courses = await listProgramCourses(programId, user.id); - return c.json({ success: true, data: courses }, 200); - } catch (error) { - return handleError(c, error, 'Failed to list program courses'); - } - } - ) - - /** - * POST /program/:programId/courses - * Add a course to a program - */ - .post( - '/:programId/courses', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('json', ZAddCourseToProgram), - async (c) => { - try { - const { programId } = c.req.valid('param'); - const data = c.req.valid('json'); - const result = await addCourseToProgramService(programId, data); - return c.json({ success: true, data: result }, 201); - } catch (error) { - return handleError(c, error, 'Failed to add course to program'); - } - } - ) - - /** - * DELETE /program/:programId/courses/:courseId - * Remove a course from a program - */ - .delete( - '/:programId/courses/:courseId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZCourseParam), - async (c) => { - try { - const { programId, courseId } = c.req.valid('param'); - const result = await removeCourseFromProgramService(programId, courseId); - return c.json({ success: true, data: result }, 200); - } catch (error) { - return handleError(c, error, 'Failed to remove course from program'); - } - } - ) - - // ── Newsfeed ────────────────────────────────────────────────────────────── - - /** - * GET /program/:programId/newsfeed - * List newsfeed for a program (paginated) - */ - .get( - '/:programId/newsfeed', - authMiddleware, - programMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('query', ZListQuery), - async (c) => { - try { - const { programId } = c.req.valid('param'); - const { cursor, limit } = c.req.valid('query'); - const result = await listProgramNewsfeed(programId, { cursor, limit }); - return c.json({ success: true, data: result }, 200); - } catch (error) { - return handleError(c, error, 'Failed to list program newsfeed'); - } - } - ) - - /** - * POST /program/:programId/newsfeed - * Create a newsfeed post - */ - .post( - '/:programId/newsfeed', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('json', ZCreateProgramNewsfeed), - async (c) => { - try { - const user = c.get('user')!; - const { programId } = c.req.valid('param'); - const data = c.req.valid('json'); - const feed = await createProgramNewsfeedService(programId, user.id, data); - return c.json({ success: true, data: feed }, 201); - } catch (error) { - return handleError(c, error, 'Failed to create program newsfeed post'); - } - } - ) - - /** - * PUT /program/:programId/newsfeed/:feedId - * Update a newsfeed post - */ - .put( - '/:programId/newsfeed/:feedId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZFeedParam), - zValidator('json', ZUpdateProgramNewsfeed), - async (c) => { - try { - const { feedId } = c.req.valid('param'); - const data = c.req.valid('json'); - const feed = await updateProgramNewsfeedService(feedId, data); - return c.json({ success: true, data: feed }, 200); - } catch (error) { - return handleError(c, error, 'Failed to update program newsfeed post'); - } - } - ) - - /** - * PUT /program/:programId/newsfeed/:feedId/react - * Update reaction on a newsfeed post - */ - .put( - '/:programId/newsfeed/:feedId/react', - authMiddleware, - programMemberMiddleware, - zValidator('param', ZFeedParam), - zValidator('json', ZUpdateProgramReaction), - async (c) => { - try { - const { feedId } = c.req.valid('param'); - const data = c.req.valid('json'); - const feed = await updateProgramNewsfeedReactionService(feedId, data); - return c.json({ success: true, data: feed }, 200); - } catch (error) { - return handleError(c, error, 'Failed to update program newsfeed reaction'); - } - } - ) - - /** - * DELETE /program/:programId/newsfeed/:feedId - * Delete a newsfeed post - */ - .delete( - '/:programId/newsfeed/:feedId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZFeedParam), - async (c) => { - try { - const { feedId } = c.req.valid('param'); - const feed = await deleteProgramNewsfeedService(feedId); - return c.json({ success: true, data: feed }, 200); - } catch (error) { - return handleError(c, error, 'Failed to delete program newsfeed post'); - } - } - ) - - /** - * GET /program/:programId/newsfeed/:feedId/comments - * List comments on a newsfeed post - */ - .get( - '/:programId/newsfeed/:feedId/comments', - authMiddleware, - programMemberMiddleware, - zValidator('param', ZFeedParam), - async (c) => { - try { - const { feedId } = c.req.valid('param'); - const comments = await listProgramNewsfeedComments(feedId); - return c.json({ success: true, data: comments }, 200); - } catch (error) { - return handleError(c, error, 'Failed to list program newsfeed comments'); - } - } - ) - - /** - * POST /program/:programId/newsfeed/:feedId/comment - * Create a comment on a newsfeed post - */ - .post( - '/:programId/newsfeed/:feedId/comment', - authMiddleware, - programMemberMiddleware, - zValidator('param', ZFeedParam), - zValidator('json', ZCreateProgramNewsfeedComment), - async (c) => { - try { - const user = c.get('user')!; - const { feedId } = c.req.valid('param'); - const data = c.req.valid('json'); - const comment = await createProgramNewsfeedCommentService(feedId, user.id, data); - return c.json({ success: true, data: comment }, 201); - } catch (error) { - return handleError(c, error, 'Failed to create program newsfeed comment'); - } - } - ) - - /** - * DELETE /program/:programId/newsfeed/:feedId/comment/:commentId - * Delete a comment on a newsfeed post - */ - .delete( - '/:programId/newsfeed/:feedId/comment/:commentId', - authMiddleware, - programNewsfeedCommentAuthorOrTeamMiddleware, - zValidator('param', ZCommentParam), - async (c) => { - try { - const { commentId } = c.req.valid('param'); - const comment = await deleteProgramNewsfeedCommentService(commentId); - return c.json({ success: true, data: comment }, 200); - } catch (error) { - return handleError(c, error, 'Failed to delete program newsfeed comment'); - } - } - ) - - // ── Goals ───────────────────────────────────────────────────────────────── - - /** - * GET /program/my/goals - * Goal assignments for the current authenticated user across all their programs. - * Used by the LMS "Your Goals" widget. - */ - .get('/my/goals', authMiddleware, async (c) => { - try { - const user = c.get('user')!; - const goals = await getMyGoals(user.id); - return c.json({ success: true, data: goals }, 200); - } catch (error) { - return handleError(c, error, 'Failed to load goals'); - } - }) - - /** - * GET /program/goals/overview?organizationId=... - * Cross-program goal roll-up for the org owner. One entry per active goal - * with per-status learner counts, scoped to the org via header + query. - */ - .get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => { - try { - const { organizationId } = c.req.valid('query'); - const overview = await getOrgGoalsOverview(organizationId); - return c.json({ success: true, data: overview }, 200); - } catch (error) { - return handleError(c, error, 'Failed to load goals overview'); - } - }) - - /** - * GET /program/:programId/goals - */ - .get('/:programId/goals', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => { - try { - const { programId } = c.req.valid('param'); - const goals = await listGoals(programId); - return c.json({ success: true, data: goals }, 200); - } catch (error) { - return handleError(c, error, 'Failed to list program goals'); - } - }) - - /** - * POST /program/:programId/goals - */ - .post( - '/:programId/goals', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - zValidator('json', ZCreateProgramGoal), - async (c) => { - try { - const user = c.get('user')!; - const { programId } = c.req.valid('param'); - const data = c.req.valid('json'); - const goal = await createGoal(programId, user.id, data); - return c.json({ success: true, data: goal }, 201); - } catch (error) { - return handleError(c, error, 'Failed to create program goal'); - } - } - ) - - /** - * GET /program/:programId/goals/:goalId - */ - .get( - '/:programId/goals/:goalId', - authMiddleware, - programMemberMiddleware, - zValidator('param', ZGoalParam), - async (c) => { - try { - const { goalId } = c.req.valid('param'); - const goal = await getGoal(goalId); - return c.json({ success: true, data: goal }, 200); - } catch (error) { - return handleError(c, error, 'Failed to get program goal'); - } - } - ) - - /** - * PUT /program/:programId/goals/:goalId - */ - .put( - '/:programId/goals/:goalId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZGoalParam), - zValidator('json', ZUpdateProgramGoal), - async (c) => { - try { - const { goalId } = c.req.valid('param'); - const data = c.req.valid('json'); - const goal = await updateGoal(goalId, data); - return c.json({ success: true, data: goal }, 200); - } catch (error) { - return handleError(c, error, 'Failed to update program goal'); - } - } - ) - - /** - * DELETE /program/:programId/goals/:goalId - * Hard delete (vs. archive). Use PUT with status=archived to preserve history. - */ - .delete( - '/:programId/goals/:goalId', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZGoalParam), - async (c) => { - try { - const { goalId } = c.req.valid('param'); - const goal = await removeGoal(goalId); - return c.json({ success: true, data: goal }, 200); - } catch (error) { - return handleError(c, error, 'Failed to delete program goal'); - } - } - ) - - /** - * POST /program/:programId/goals/:goalId/archive - */ - .post( - '/:programId/goals/:goalId/archive', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZGoalParam), - async (c) => { - try { - const { goalId } = c.req.valid('param'); - const goal = await archiveGoal(goalId); - return c.json({ success: true, data: goal }, 200); - } catch (error) { - return handleError(c, error, 'Failed to archive program goal'); - } - } - ) - - /** - * POST /program/:programId/goals/:goalId/evaluate - * Force a re-evaluation of one goal's per-learner statuses. - */ - .post( - '/:programId/goals/:goalId/evaluate', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZGoalParam), - async (c) => { - try { - const { goalId } = c.req.valid('param'); - const result = await evaluateGoal(goalId); - return c.json({ success: true, data: result }, 200); - } catch (error) { - return handleError(c, error, 'Failed to evaluate program goal'); - } - } - ) - - /** - * POST /program/:programId/goals/evaluate-all - * Force a re-evaluation of every active goal in the program. - */ - .post( - '/:programId/goals/evaluate-all', - authMiddleware, - programTeamMemberMiddleware, - zValidator('param', ZProgramParam), - async (c) => { - try { - const { programId } = c.req.valid('param'); - const result = await evaluateProgramGoals(programId); - return c.json({ success: true, data: result }, 200); - } catch (error) { - return handleError(c, error, 'Failed to evaluate program goals'); - } - } - ); diff --git a/apps/api/src/services/program/program.ts b/apps/api/src/services/cohort/cohort.ts similarity index 55% rename from apps/api/src/services/program/program.ts rename to apps/api/src/services/cohort/cohort.ts index fff0573e..e7ec30fa 100644 --- a/apps/api/src/services/program/program.ts +++ b/apps/api/src/services/cohort/cohort.ts @@ -1,44 +1,44 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { - type TCreateProgram, - type TUpdateProgram, - type TAddCourseToProgram, - type TAddProgramMembers, - type TUpdateProgramMember, - type TCreateProgramNewsfeed, - type TUpdateProgramNewsfeed, - type TUpdateProgramReaction, - type TCreateProgramNewsfeedComment -} from '@cio/utils/validation/program'; + type TCreateCohort, + type TUpdateCohort, + type TAddCourseToCohort, + type TAddCohortMembers, + type TUpdateCohortMember, + type TCreateCohortNewsfeed, + type TUpdateCohortNewsfeed, + type TUpdateCohortReaction, + type TCreateCohortNewsfeedComment +} from '@cio/utils/validation/cohort'; import { - addCourseToProgram, - addProgramMember, - createProgramWithCreatorMembership, - createProgramNewsfeed as createProgramNewsfeedQuery, - createProgramNewsfeedComment as createProgramNewsfeedCommentQuery, - deleteProgram as deleteProgramQuery, - deleteProgramNewsfeed as deleteProgramNewsfeedQuery, - deleteProgramNewsfeedComment as deleteProgramNewsfeedCommentQuery, - getEnrolledProgramsByProfile, - getProgramById, - getProgramMemberByProfileId, - getProgramMembers, - getProgramNewsfeed, - getProgramNewsfeedById, - getProgramNewsfeedCommentById, - getProgramNewsfeedComments, - getProgramsByOrg, - getCoursesByProgram, - getProgramMemberRole, - isProgramCourse, - isProgramMember, - removeCourseFromProgram, - removeProgramMember, - updateProgram as updateProgramQuery, - updateProgramMember as updateProgramMemberQuery, - updateProgramNewsfeed as updateProgramNewsfeedQuery, - updateProgramNewsfeedReaction -} from '@cio/db/queries/program'; + addCourseToCohort, + addCohortMember, + createCohortWithCreatorMembership, + createCohortNewsfeed as createCohortNewsfeedQuery, + createCohortNewsfeedComment as createCohortNewsfeedCommentQuery, + deleteCohort as deleteCohortQuery, + deleteCohortNewsfeed as deleteCohortNewsfeedQuery, + deleteCohortNewsfeedComment as deleteCohortNewsfeedCommentQuery, + getEnrolledCohortsByProfile, + getCohortById, + getCohortMemberByProfileId, + getCohortMembers, + getCohortNewsfeed, + getCohortNewsfeedById, + getCohortNewsfeedCommentById, + getCohortNewsfeedComments, + getCohortsByOrg, + getCoursesByCohort, + getCohortMemberRole, + isCohortCourse, + isCohortMember, + removeCourseFromCohort, + removeCohortMember, + updateCohort as updateCohortQuery, + updateCohortMember as updateCohortMemberQuery, + updateCohortNewsfeed as updateCohortNewsfeedQuery, + updateCohortNewsfeedReaction +} from '@cio/db/queries/cohort'; import { getCourseGroupIds } from '@cio/db/queries/course'; import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group'; import { getProfileByEmail } from '@cio/db/queries/auth'; @@ -46,16 +46,16 @@ import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/or import { ROLE } from '@cio/utils/constants'; import { db } from '@cio/db/drizzle'; -type ProgramMemberEnrollment = { +type CohortMemberEnrollment = { profileId: string; email: string | null; roleId: number; }; -async function enrollProgramStudentsInGroups( +async function enrollCohortStudentsInGroups( organizationId: string, groupIds: string[], - members: ProgramMemberEnrollment[] + members: CohortMemberEnrollment[] ) { const studentMembers = members.filter((member) => member.profileId && member.roleId === ROLE.STUDENT); const uniqueGroupIds = [...new Set(groupIds)]; @@ -91,11 +91,11 @@ async function enrollProgramStudentsInGroups( // ─── Program CRUD ──────────────────────────────────────────────────────────── -export async function createProgram(organizationId: string, profileId: string, data: TCreateProgram) { +export async function createCohort(organizationId: string, profileId: string, data: TCreateCohort) { try { const description = data.description?.trim() || `A program for ${data.name}`; - return createProgramWithCreatorMembership( + return createCohortWithCreatorMembership( { organizationId, createdByProfileId: profileId, @@ -108,72 +108,72 @@ export async function createProgram(organizationId: string, profileId: string, d } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to create program', - ErrorCodes.PROGRAM_CREATE_FAILED, + error instanceof Error ? error.message : 'Failed to create cohort', + ErrorCodes.COHORT_CREATE_FAILED, 500 ); } } -export async function getProgram(programId: string) { +export async function getCohort(cohortId: string) { try { - const program = await getProgramById(programId); - if (!program) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); + const cohort = await getCohortById(cohortId); + if (!cohort) { + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); } - return program; + return cohort; } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to get program', - ErrorCodes.PROGRAM_NOT_FOUND, + error instanceof Error ? error.message : 'Failed to get cohort', + ErrorCodes.COHORT_NOT_FOUND, 500 ); } } -export async function listOrgPrograms(organizationId: string) { +export async function listOrgCohorts(organizationId: string) { try { - return getProgramsByOrg(organizationId); + return getCohortsByOrg(organizationId); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to list programs', + error instanceof Error ? error.message : 'Failed to list cohorts', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function updateProgram(programId: string, data: TUpdateProgram) { +export async function updateCohort(cohortId: string, data: TUpdateCohort) { try { - const updated = await updateProgramQuery(programId, data); + const updated = await updateCohortQuery(cohortId, data); if (!updated) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); } return updated; } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to update program', - ErrorCodes.PROGRAM_UPDATE_FAILED, + error instanceof Error ? error.message : 'Failed to update cohort', + ErrorCodes.COHORT_UPDATE_FAILED, 500 ); } } -export async function deleteProgram(programId: string) { +export async function deleteCohort(cohortId: string) { try { - const deleted = await deleteProgramQuery(programId); + const deleted = await deleteCohortQuery(cohortId); if (!deleted) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); } return deleted; } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to delete program', - ErrorCodes.PROGRAM_DELETE_FAILED, + error instanceof Error ? error.message : 'Failed to delete cohort', + ErrorCodes.COHORT_DELETE_FAILED, 500 ); } @@ -181,28 +181,28 @@ export async function deleteProgram(programId: string) { // ─── Program Members ───────────────────────────────────────────────────────── -export async function listProgramMembers(programId: string) { +export async function listCohortMembers(cohortId: string) { try { - return getProgramMembers(programId); + return getCohortMembers(cohortId); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to list program members', + error instanceof Error ? error.message : 'Failed to list cohort members', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function addProgramMembers(programId: string, data: TAddProgramMembers) { +export async function addCohortMembers(cohortId: string, data: TAddCohortMembers) { try { - const program = await getProgramById(programId); - if (!program) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); + const cohort = await getCohortById(cohortId); + if (!cohort) { + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); } - const programCourses = await getCoursesByProgram(programId); - const courseGroupIds = (await getCourseGroupIds(programCourses.map((course) => course.course.id))).map( + const cohortCourses = await getCoursesByCohort(cohortId); + const courseGroupIds = (await getCourseGroupIds(cohortCourses.map((course) => course.course.id))).map( (courseGroup) => courseGroup.groupId ); @@ -213,13 +213,9 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb const normalizedEmail = email?.toLowerCase().trim() ?? profile?.email ?? null; if (profileId) { - const existing = await getProgramMemberByProfileId(programId, profileId); + const existing = await getCohortMemberByProfileId(cohortId, profileId); if (existing) { - throw new AppError( - `${email} is already a member of this program`, - ErrorCodes.MEMBER_ALREADY_IN_PROGRAM, - 409 - ); + throw new AppError(`${email} is already a member of this cohort`, ErrorCodes.MEMBER_ALREADY_IN_COHORT, 409); } } @@ -232,9 +228,9 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb } return db.transaction(async (tx) => { - const member = await addProgramMember( + const member = await addCohortMember( { - programId, + cohortId, profileId, roleId, email: normalizedEmail ?? undefined @@ -248,7 +244,7 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb await insertOrganizationMembersOnConflictDoNothing( [ { - organizationId: program.organizationId, + organizationId: cohort.organizationId, roleId: ROLE.STUDENT, profileId: member.profileId, email: normalizedEmail ?? undefined, @@ -285,18 +281,18 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to add program members', + error instanceof Error ? error.message : 'Failed to add cohort members', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function removeProgramMemberService(_programId: string, memberId: string) { +export async function removeCohortMemberService(_cohortId: string, memberId: string) { try { - const deleted = await removeProgramMember(memberId); + const deleted = await removeCohortMember(memberId); if (!deleted) { - throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404); + throw new AppError('Cohort member not found', ErrorCodes.COHORT_MEMBER_NOT_FOUND, 404); } return deleted; } catch (error) { @@ -309,30 +305,30 @@ export async function removeProgramMemberService(_programId: string, memberId: s } } -export async function updateProgramMemberService(_programId: string, memberId: string, data: TUpdateProgramMember) { +export async function updateCohortMemberService(_cohortId: string, memberId: string, data: TUpdateCohortMember) { try { - const updated = await updateProgramMemberQuery(memberId, { roleId: data.roleId }); + const updated = await updateCohortMemberQuery(memberId, { roleId: data.roleId }); if (!updated) { - throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404); + throw new AppError('Cohort member not found', ErrorCodes.COHORT_MEMBER_NOT_FOUND, 404); } return updated; } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to update program member', + error instanceof Error ? error.message : 'Failed to update cohort member', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function getEnrolledPrograms(profileId: string) { +export async function getEnrolledCohorts(profileId: string) { try { - return getEnrolledProgramsByProfile(profileId); + return getEnrolledCohortsByProfile(profileId); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to get enrolled programs', + error instanceof Error ? error.message : 'Failed to get enrolled cohorts', ErrorCodes.INTERNAL_ERROR, 500 ); @@ -341,36 +337,36 @@ export async function getEnrolledPrograms(profileId: string) { // ─── Program Courses ───────────────────────────────────────────────────────── -export async function listProgramCourses(programId: string, profileId: string) { +export async function listCohortCourses(cohortId: string, profileId: string) { try { - const roleId = await getProgramMemberRole(programId, profileId); + const roleId = await getCohortMemberRole(cohortId, profileId); const onlyPublished = roleId === ROLE.STUDENT; - return getCoursesByProgram(programId, onlyPublished); + return getCoursesByCohort(cohortId, onlyPublished); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to list program courses', + error instanceof Error ? error.message : 'Failed to list cohort courses', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function addCourseToProgramService(programId: string, data: TAddCourseToProgram) { +export async function addCourseToCohortService(cohortId: string, data: TAddCourseToCohort) { try { - const program = await getProgramById(programId); - if (!program) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); + const cohort = await getCohortById(cohortId); + if (!cohort) { + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); } - const alreadyAdded = await isProgramCourse(programId, data.courseId); + const alreadyAdded = await isCohortCourse(cohortId, data.courseId); if (alreadyAdded) { - throw new AppError('Course is already in this program', ErrorCodes.COURSE_ALREADY_IN_PROGRAM, 409); + throw new AppError('Course is already in this cohort', ErrorCodes.COURSE_ALREADY_IN_COHORT, 409); } - const result = await addCourseToProgram(programId, data.courseId); - const students = (await getProgramMembers(programId)) + const result = await addCourseToCohort(cohortId, data.courseId); + const students = (await getCohortMembers(cohortId)) .filter((member) => member.roleId === ROLE.STUDENT && member.profileId) .map((member) => ({ profileId: member.profileId!, @@ -383,7 +379,7 @@ export async function addCourseToProgramService(programId: string, data: TAddCou .filter((groupId): groupId is string => Boolean(groupId)); if (students.length > 0 && courseGroupIds.length > 0) { - await enrollProgramStudentsInGroups(program.organizationId, courseGroupIds, students); + await enrollCohortStudentsInGroups(cohort.organizationId, courseGroupIds, students); } return result; @@ -397,9 +393,9 @@ export async function addCourseToProgramService(programId: string, data: TAddCou } } -export async function removeCourseFromProgramService(programId: string, courseId: string) { +export async function removeCourseFromCohortService(cohortId: string, courseId: string) { try { - const deleted = await removeCourseFromProgram(programId, courseId); + const deleted = await removeCourseFromCohort(cohortId, courseId); if (!deleted) { throw new AppError('Course not found in program', ErrorCodes.NOT_FOUND, 404); } @@ -416,28 +412,28 @@ export async function removeCourseFromProgramService(programId: string, courseId // ─── Program Newsfeed ──────────────────────────────────────────────────────── -export async function listProgramNewsfeed(programId: string, options: { cursor?: string; limit: number }) { +export async function listCohortNewsfeed(cohortId: string, options: { cursor?: string; limit: number }) { try { - return getProgramNewsfeed(programId, options); + return getCohortNewsfeed(cohortId, options); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to list program newsfeed', + error instanceof Error ? error.message : 'Failed to list cohort newsfeed', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function createProgramNewsfeedService(programId: string, profileId: string, data: TCreateProgramNewsfeed) { +export async function createCohortNewsfeedService(cohortId: string, profileId: string, data: TCreateCohortNewsfeed) { try { - const member = await getProgramMemberByProfileId(programId, profileId); + const member = await getCohortMemberByProfileId(cohortId, profileId); if (!member) { - throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403); + throw new AppError('User is not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403); } - return createProgramNewsfeedQuery({ - programId, + return createCohortNewsfeedQuery({ + cohortId, authorId: member.id, content: data.content, isPinned: data.isPinned ?? false @@ -445,58 +441,58 @@ export async function createProgramNewsfeedService(programId: string, profileId: } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to create program newsfeed', + error instanceof Error ? error.message : 'Failed to create cohort newsfeed', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function updateProgramNewsfeedService(feedId: string, data: TUpdateProgramNewsfeed) { +export async function updateCohortNewsfeedService(feedId: string, data: TUpdateCohortNewsfeed) { try { - const feed = await getProgramNewsfeedById(feedId); + const feed = await getCohortNewsfeedById(feedId); if (!feed) { - throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404); + throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404); } - return updateProgramNewsfeedQuery(feedId, data); + return updateCohortNewsfeedQuery(feedId, data); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to update program newsfeed', + error instanceof Error ? error.message : 'Failed to update cohort newsfeed', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function updateProgramNewsfeedReactionService(feedId: string, data: TUpdateProgramReaction) { +export async function updateCohortNewsfeedReactionService(feedId: string, data: TUpdateCohortReaction) { try { - const feed = await getProgramNewsfeedById(feedId); + const feed = await getCohortNewsfeedById(feedId); if (!feed) { - throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404); + throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404); } - return updateProgramNewsfeedReaction(feedId, data.reaction); + return updateCohortNewsfeedReaction(feedId, data.reaction); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to update program newsfeed reaction', + error instanceof Error ? error.message : 'Failed to update cohort newsfeed reaction', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function deleteProgramNewsfeedService(feedId: string) { +export async function deleteCohortNewsfeedService(feedId: string) { try { - const feed = await getProgramNewsfeedById(feedId); + const feed = await getCohortNewsfeedById(feedId); if (!feed) { - throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404); + throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404); } - return deleteProgramNewsfeedQuery(feedId); + return deleteCohortNewsfeedQuery(feedId); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to delete program newsfeed', + error instanceof Error ? error.message : 'Failed to delete cohort newsfeed', ErrorCodes.INTERNAL_ERROR, 500 ); @@ -505,67 +501,67 @@ export async function deleteProgramNewsfeedService(feedId: string) { // ─── Program Newsfeed Comments ──────────────────────────────────────────────── -export async function listProgramNewsfeedComments(feedId: string) { +export async function listCohortNewsfeedComments(feedId: string) { try { - const feed = await getProgramNewsfeedById(feedId); + const feed = await getCohortNewsfeedById(feedId); if (!feed) { - throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404); + throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404); } - return getProgramNewsfeedComments(feedId); + return getCohortNewsfeedComments(feedId); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to list program newsfeed comments', + error instanceof Error ? error.message : 'Failed to list cohort newsfeed comments', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function createProgramNewsfeedCommentService( +export async function createCohortNewsfeedCommentService( feedId: string, profileId: string, - data: TCreateProgramNewsfeedComment + data: TCreateCohortNewsfeedComment ) { try { - const feed = await getProgramNewsfeedById(feedId); + const feed = await getCohortNewsfeedById(feedId); if (!feed) { - throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404); + throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404); } - const member = await getProgramMemberByProfileId(feed.programId!, profileId); + const member = await getCohortMemberByProfileId(feed.cohortId!, profileId); if (!member) { - throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403); + throw new AppError('User is not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403); } - return createProgramNewsfeedCommentQuery({ - programNewsfeedId: feedId, + return createCohortNewsfeedCommentQuery({ + cohortNewsfeedId: feedId, authorId: member.id, content: data.content }); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to create program newsfeed comment', + error instanceof Error ? error.message : 'Failed to create cohort newsfeed comment', ErrorCodes.INTERNAL_ERROR, 500 ); } } -export async function deleteProgramNewsfeedCommentService(commentId: number) { +export async function deleteCohortNewsfeedCommentService(commentId: number) { try { - const comment = await getProgramNewsfeedCommentById(commentId); + const comment = await getCohortNewsfeedCommentById(commentId); if (!comment) { - throw new AppError('Comment not found', ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND, 404); + throw new AppError('Comment not found', ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND, 404); } - const feed = await getProgramNewsfeedById(comment.programNewsfeedId!); + const feed = await getCohortNewsfeedById(comment.cohortNewsfeedId!); if (!feed) { - throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404); + throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404); } - return deleteProgramNewsfeedCommentQuery(commentId); + return deleteCohortNewsfeedCommentQuery(commentId); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( - error instanceof Error ? error.message : 'Failed to delete program newsfeed comment', + error instanceof Error ? error.message : 'Failed to delete cohort newsfeed comment', ErrorCodes.INTERNAL_ERROR, 500 ); @@ -574,9 +570,9 @@ export async function deleteProgramNewsfeedCommentService(commentId: number) { // ─── Auth Helpers ──────────────────────────────────────────────────────────── -async function assertProgramMember(programId: string, profileId: string) { - const member = await isProgramMember(programId, profileId); +async function assertCohortMember(cohortId: string, profileId: string) { + const member = await isCohortMember(cohortId, profileId); if (!member) { - throw new AppError('You are not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403); + throw new AppError('You are not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403); } } diff --git a/apps/api/src/services/program/goal.ts b/apps/api/src/services/cohort/goal.ts similarity index 77% rename from apps/api/src/services/program/goal.ts rename to apps/api/src/services/cohort/goal.ts index 12a5326e..8830103a 100644 --- a/apps/api/src/services/program/goal.ts +++ b/apps/api/src/services/cohort/goal.ts @@ -4,49 +4,45 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { buildEmailBranding } from '@cio/email'; import { countAssignmentsByStatus, - createProgramGoal, - deleteProgramGoal, + createCohortGoal, + deleteCohortGoal, getAssignmentsForProfile, getLatestCompletionRecordsForProfilesAndCourses, getMaxScoresForProfilesAndCourses, getNonComplianceCourseCompletions, - getProgramById, - getProgramGoalAssignments, - getProgramGoalById, - getProgramGoals, - getProgramGoalsByOrg, - getProgramMembers, - listAllActiveProgramGoals, + getCohortById, + getCohortGoalAssignments, + getCohortGoalById, + getCohortGoals, + getCohortGoalsByOrg, + getCohortMembers, + listAllActiveCohortGoals, listAssignmentsForReminderScan, - type TNewProgramGoalAssignment, - type TProgramGoal, - type TProgramGoalAssignment, - updateProgramGoal as updateProgramGoalQuery, - upsertProgramGoalAssignments -} from '@cio/db/queries/program'; + type TNewCohortGoalAssignment, + type TCohortGoal, + type TCohortGoalAssignment, + updateCohortGoal as updateCohortGoalQuery, + upsertCohortGoalAssignments +} from '@cio/db/queries/cohort'; import { ROLE } from '@cio/utils/constants'; -import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program'; +import type { TCreateCohortGoal, TUpdateCohortGoal } from '@cio/utils/validation/cohort'; const AT_RISK_DAYS = 3; const DAY_MS = 24 * 60 * 60 * 1000; -type GoalType = TProgramGoal['type']; +type GoalType = TCohortGoal['type']; // ─── CRUD ──────────────────────────────────────────────────────────────────── -export async function createGoal( - programId: string, - profileId: string, - data: TCreateProgramGoal -): Promise { - const program = await getProgramById(programId); - if (!program) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); +export async function createGoal(cohortId: string, profileId: string, data: TCreateCohortGoal): Promise { + const cohort = await getCohortById(cohortId); + if (!cohort) { + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); } try { - const goal = await createProgramGoal({ - programId, + const goal = await createCohortGoal({ + cohortId, title: data.title, description: data.description ?? null, type: data.type, @@ -71,16 +67,16 @@ export async function createGoal( if (error instanceof AppError) throw error; throw new AppError( error instanceof Error ? error.message : 'Failed to create goal', - ErrorCodes.PROGRAM_GOAL_CREATE_FAILED, + ErrorCodes.COHORT_GOAL_CREATE_FAILED, 500 ); } } export async function listGoals( - programId: string -): Promise }>> { - const goals = await getProgramGoals(programId); + cohortId: string +): Promise }>> { + const goals = await getCohortGoals(cohortId); if (goals.length === 0) return []; const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id)); @@ -91,23 +87,23 @@ export async function listGoals( })); } -export async function getGoal(goalId: string): Promise { - const goal = await getProgramGoalById(goalId); +export async function getGoal(goalId: string): Promise { + const goal = await getCohortGoalById(goalId); if (!goal) { - throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404); + throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404); } return goal; } -export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Promise { - const existing = await getProgramGoalById(goalId); +export async function updateGoal(goalId: string, data: TUpdateCohortGoal): Promise { + const existing = await getCohortGoalById(goalId); if (!existing) { - throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404); + throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404); } try { - const updated = await updateProgramGoalQuery(goalId, { + const updated = await updateCohortGoalQuery(goalId, { title: data.title, description: data.description ?? undefined, type: data.type, @@ -123,7 +119,7 @@ export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Prom status: data.status }); if (!updated) { - throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404); + throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404); } await evaluateGoal(goalId).catch((error) => { @@ -135,20 +131,20 @@ export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Prom if (error instanceof AppError) throw error; throw new AppError( error instanceof Error ? error.message : 'Failed to update goal', - ErrorCodes.PROGRAM_GOAL_UPDATE_FAILED, + ErrorCodes.COHORT_GOAL_UPDATE_FAILED, 500 ); } } -export async function archiveGoal(goalId: string): Promise { +export async function archiveGoal(goalId: string): Promise { return updateGoal(goalId, { status: 'archived' }); } -export async function removeGoal(goalId: string): Promise { - const deleted = await deleteProgramGoal(goalId); +export async function removeGoal(goalId: string): Promise { + const deleted = await deleteCohortGoal(goalId); if (!deleted) { - throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404); + throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404); } return deleted; @@ -164,7 +160,7 @@ export async function removeGoal(goalId: string): Promise { * - relative_to_join → joinedAt + relativeDays * - recurring → goal.deadlineDate (current cycle); cycle advance handled elsewhere */ -function computeDueDate(goal: TProgramGoal, joinedAt: Date): Date | null { +function computeDueDate(goal: TCohortGoal, joinedAt: Date): Date | null { if (goal.deadlineKind === 'none') return null; if (goal.deadlineKind === 'absolute') { return goal.deadlineDate ? new Date(goal.deadlineDate) : null; @@ -193,8 +189,8 @@ function decideStatus(args: { required: number; dueDate: Date | null; now: Date; - previousStatus: TProgramGoalAssignment['status'] | null; -}): { status: TProgramGoalAssignment['status']; completedAt: Date | null } { + previousStatus: TCohortGoalAssignment['status'] | null; +}): { status: TCohortGoalAssignment['status']; completedAt: Date | null } { const { completed, required, dueDate, now, previousStatus } = args; if (previousStatus === 'waived') { @@ -223,15 +219,15 @@ function decideStatus(args: { /** * Re-evaluate every assignment for one goal. * - * Pulls all student program-members, computes a per-learner status, and upserts + * Pulls all student cohort-members, computes a per-learner status, and upserts * `program_goal_assignment`. Designed to be safe to run repeatedly (idempotent) * and cheap enough for daily cron over hundreds of programs. */ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }> { - const goal = await getProgramGoalById(goalId); + const goal = await getCohortGoalById(goalId); if (!goal) return { evaluated: 0 }; - const members = await getProgramMembers(goal.programId); + const members = await getCohortMembers(goal.cohortId); const studentMembers = members.filter((member) => member.roleId === ROLE.STUDENT && member.profileId); if (studentMembers.length === 0) return { evaluated: 0 }; @@ -244,7 +240,7 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number goal.type === 'score' || goal.type === 'pass_rate' ? getMaxScoresForProfilesAndCourses(profileIds, courseIds) : Promise.resolve(new Map()), - getProgramGoalAssignments(goalId) + getCohortGoalAssignments(goalId) ]); const completionByKey = new Map(); @@ -255,13 +251,13 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }); } - const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.programMemberId, assignment])); + const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.cohortMemberId, assignment])); const now = new Date(); const requiredFor = (type: GoalType, total: number) => type === 'n_of_m' && goal.requiredCount ? Math.min(goal.requiredCount, total) : total; - const rows: TNewProgramGoalAssignment[] = []; + const rows: TNewCohortGoalAssignment[] = []; for (const member of studentMembers) { if (!member.profileId) continue; @@ -303,7 +299,7 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number rows.push({ goalId, - programMemberId: member.id, + cohortMemberId: member.id, dueDate: dueDate ? dueDate.toISOString() : null, status: decision.status, completedCount: completed, @@ -314,17 +310,17 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }); } - await upsertProgramGoalAssignments(rows); + await upsertCohortGoalAssignments(rows); return { evaluated: rows.length }; } /** - * Re-evaluate every active goal in a program. Used after material program + * Re-evaluate every active goal in a cohort. Used after material program * changes (member added, course added) or by cron sweeps. */ -export async function evaluateProgramGoals(programId: string): Promise<{ evaluated: number }> { - const goals = await getProgramGoals(programId); +export async function evaluateCohortGoals(cohortId: string): Promise<{ evaluated: number }> { + const goals = await getCohortGoals(cohortId); let evaluated = 0; for (const goal of goals) { const result = await evaluateGoal(goal.id); @@ -338,7 +334,7 @@ export async function evaluateProgramGoals(programId: string): Promise<{ evaluat * Re-evaluate all goals in an organization. Used by the daily cron. */ export async function evaluateOrgGoals(organizationId: string): Promise<{ evaluated: number; goals: number }> { - const goals = await getProgramGoalsByOrg(organizationId); + const goals = await getCohortGoalsByOrg(organizationId); let evaluated = 0; for (const goal of goals) { const result = await evaluateGoal(goal.id); @@ -352,11 +348,11 @@ export async function evaluateOrgGoals(organizationId: string): Promise<{ evalua * Re-evaluate every active goal across every org. Designed for the daily cron * sweep — same shape as `runComplianceExpiryCheck` for the compliance feature. */ -export async function runProgramGoalEvaluationSweep(): Promise<{ +export async function runCohortGoalEvaluationSweep(): Promise<{ goalsEvaluated: number; assignmentsEvaluated: number; }> { - const goals = await listAllActiveProgramGoals(); + const goals = await listAllActiveCohortGoals(); let assignmentsEvaluated = 0; for (const goal of goals) { @@ -364,7 +360,7 @@ export async function runProgramGoalEvaluationSweep(): Promise<{ const result = await evaluateGoal(goal.id); assignmentsEvaluated += result.evaluated; } catch (error) { - console.error(`runProgramGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error); + console.error(`runCohortGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error); } } @@ -386,7 +382,7 @@ function getWholeDaysUntil(now: Date, target: Date): number { * Idempotency: the email worker idempotency key is keyed on assignment id + * the day bucket so a second run on the same day does not re-send. */ -export async function runProgramGoalReminderScan(): Promise<{ +export async function runCohortGoalReminderScan(): Promise<{ scanned: number; remindersEnqueued: number; }> { @@ -417,27 +413,27 @@ export async function runProgramGoalReminderScan(): Promise<{ const dayKey = now.toISOString().slice(0, 10); try { - await enqueueTransactionalEmail('programGoalReminder', { + await enqueueTransactionalEmail('cohortGoalReminder', { to: row.email, fields: { orgName: row.organizationName, - programName: row.programName, + cohortName: row.cohortName, goalTitle: row.goal.title, daysUntilDue, completedCount: row.assignment.completedCount ?? 0, requiredCount: row.assignment.requiredCount ?? 0, - loginUrl: `${loginUrl}/lms/programs/${row.programId}`, + loginUrl: `${loginUrl}/lms/cohorts/${row.cohortId}`, branding: buildEmailBranding({ name: row.organizationName, avatarUrl: row.organizationAvatarUrl, theme: row.organizationTheme }) }, - idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}` + idempotencyKey: `cohort-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}` }); remindersEnqueued += 1; } catch (error) { - console.error(`runProgramGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error); + console.error(`runCohortGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error); } } @@ -453,11 +449,11 @@ export async function getMyGoals(profileId: string) { // ─── Org-wide overview ─────────────────────────────────────────────────────── /** - * Cross-program goal roll-up for the org owner. Lists every active goal in the + * Cross-cohort goal roll-up for the org owner. Lists every active goal in the * org with its per-status counts so the dashboard can group by program. */ export async function getOrgGoalsOverview(organizationId: string) { - const goals = await getProgramGoalsByOrg(organizationId); + const goals = await getCohortGoalsByOrg(organizationId); if (goals.length === 0) { return { goals: [] }; } @@ -476,8 +472,8 @@ export async function getOrgGoalsOverview(organizationId: string) { return { goalId: goal.id, - programId: goal.programId, - programName: goal.programName, + cohortId: goal.cohortId, + cohortName: goal.cohortName, title: goal.title, type: goal.type, deadlineKind: goal.deadlineKind, diff --git a/apps/api/src/services/cohort/invite.ts b/apps/api/src/services/cohort/invite.ts new file mode 100644 index 00000000..19d75f9e --- /dev/null +++ b/apps/api/src/services/cohort/invite.ts @@ -0,0 +1,52 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { getCohortById } from '@cio/db/queries/cohort'; +import type { TInviteStudentsToCohort, TAssignExistingStudentsToCohort } from '@cio/utils/validation/cohort'; +import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience'; + +/** + * Invite students to a single program by email CSV. + * + * Thin wrapper that hardcodes `cohortIds = [cohortId]` and delegates to + * `importAudienceMembers`. Lets a Cohort ADMIN/TUTOR (or org admin) issue org + * invites scoped to *their own* program, without granting org-wide invite rights. + */ +export async function inviteStudentsToCohort( + cohortId: string, + data: TInviteStudentsToCohort, + invitedByProfileId: string +) { + const cohort = await getCohortById(cohortId); + if (!cohort) { + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); + } + + return importAudienceMembers( + cohort.organizationId, + { + recipientCsv: data.recipientCsv, + cohortIds: [cohortId], + sendEmail: data.sendEmail, + allCourses: false, + allCohorts: false + }, + invitedByProfileId + ); +} + +/** + * Assign existing student profiles (already members of the org audience) to a + * single program. Same scoping logic as `inviteStudentsToCohort` — restricted + * to one program at a time so the caller's program-admin permission is enough. + */ +export async function assignExistingStudentsToCohort(cohortId: string, data: TAssignExistingStudentsToCohort) { + const cohort = await getCohortById(cohortId); + if (!cohort) { + throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404); + } + + return assignAudienceToCourses(cohort.organizationId, { + profileIds: data.profileIds, + cohortIds: [cohortId], + sendEmail: data.sendEmail + }); +} diff --git a/apps/api/src/services/organization/audience.ts b/apps/api/src/services/organization/audience.ts index 4d079f77..dd357282 100644 --- a/apps/api/src/services/organization/audience.ts +++ b/apps/api/src/services/organization/audience.ts @@ -7,7 +7,7 @@ import type { import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group'; import { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; -import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program'; +import { addCohortMember, getExistingCohortMembers, getCohortsByOrg } from '@cio/db/queries/cohort'; import { createOrganizationInvite, createOrganizationInviteAudits, @@ -132,21 +132,21 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud return { courseIds, courseNames }; } -async function resolveProgramIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) { - let programIds: string[] = []; - let programNames: string[] = []; +async function resolveCohortIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) { + let cohortIds: string[] = []; + let cohortNames: string[] = []; - if (data.allPrograms) { - const programs = await getProgramsByOrg(orgId); - programIds = programs.map((program) => program.id); - programNames = programs.map((program) => program.name).filter(Boolean); - } else if (data.programIds && data.programIds.length > 0) { - const programs = await getProgramsByOrg(orgId, data.programIds); - programIds = programs.map((program) => program.id); - programNames = programs.map((program) => program.name).filter(Boolean); + if (data.allCohorts) { + const cohorts = await getCohortsByOrg(orgId); + cohortIds = cohorts.map((cohort) => cohort.id); + cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean); + } else if (data.cohortIds && data.cohortIds.length > 0) { + const cohorts = await getCohortsByOrg(orgId, data.cohortIds); + cohortIds = cohorts.map((cohort) => cohort.id); + cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean); } - return { programIds, programNames }; + return { cohortIds, cohortNames }; } async function enrollAudienceStudentProfilesInCourses( @@ -241,14 +241,14 @@ async function enrollAudienceStudentProfilesInCourses( }; } -async function enrollAudienceStudentProfilesInPrograms( +async function enrollAudienceStudentProfilesInCohorts( organization: NonNullable>>, orgId: string, profileIds: string[], - programIds: string[], + cohortIds: string[], shouldSendEmail: boolean ): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> { - if (programIds.length === 0 || profileIds.length === 0) { + if (cohortIds.length === 0 || profileIds.length === 0) { return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 }; } @@ -260,25 +260,25 @@ async function enrollAudienceStudentProfilesInPrograms( studentMembers.filter((member) => member.profileId).map((member) => [member.profileId!, member.email ?? '']) ); - const programs = await getProgramsByOrg(orgId, programIds); - if (programs.length === 0) { + const cohorts = await getCohortsByOrg(orgId, cohortIds); + if (cohorts.length === 0) { return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 }; } - const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program'])); + const cohortNameById = new Map(cohorts.map((cohort) => [cohort.id, cohort.name || 'Cohort'])); const loginUrl = getDashboardBaseUrl(organization); - const validProgramIds = programs.map((program) => program.id); + const validCohortIds = cohorts.map((cohort) => cohort.id); const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId)); - const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId }))); - const existingSet = await getExistingProgramMembers(pairs); - const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.programId}:${pair.profileId}`)); + const pairs = validProfiles.flatMap((profileId) => validCohortIds.map((cohortId) => ({ cohortId, profileId }))); + const existingSet = await getExistingCohortMembers(pairs); + const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.cohortId}:${pair.profileId}`)); const alreadyEnrolled = pairs.length - toInsert.length; if (toInsert.length > 0) { await Promise.all( toInsert.map((pair) => - addProgramMember({ - programId: pair.programId, + addCohortMember({ + cohortId: pair.cohortId, roleId: ROLE.STUDENT, profileId: pair.profileId, email: profileEmailMap.get(pair.profileId) || undefined @@ -297,20 +297,20 @@ async function enrollAudienceStudentProfilesInPrograms( const email = profileEmailMap.get(pair.profileId)!; try { - await enqueueTransactionalEmail('studentProgramWelcome', { + await enqueueTransactionalEmail('studentCohortWelcome', { to: email, fields: { orgName: organization.name, - programName: programNameById.get(pair.programId) || 'Program', + cohortName: cohortNameById.get(pair.cohortId) || 'Cohort', loginUrl, branding: buildEmailBranding(organization) }, from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), - idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}` + idempotencyKey: `audience-cohort-welcome:${pair.cohortId}:${pair.profileId}` }); emailsSent++; } catch (emailError) { - console.error(`enrollAudienceStudentProfilesInPrograms enqueue error for ${email}:`, emailError); + console.error(`enrollAudienceStudentProfilesInCohorts enqueue error for ${email}:`, emailError); } }) ); @@ -328,12 +328,12 @@ async function createStudentOrgInvitesAndSendEmails(input: { organization: NonNullable>>; emails: string[]; courseIds: string[]; - programIds: string[]; + cohortIds: string[]; accessNamesLabel: string | undefined; invitedByProfileId: string; shouldSendEmail: boolean; }): Promise<{ created: number; emailsSent: number; emailsFailed: number }> { - const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } = + const { orgId, organization, emails, courseIds, cohortIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } = input; if (emails.length === 0) { @@ -360,7 +360,7 @@ async function createStudentOrgInvitesAndSendEmails(input: { metadata: { source: 'AUDIENCE_IMPORT', courseIds: courseIds.length > 0 ? courseIds : undefined, - programIds: programIds.length > 0 ? programIds : undefined + cohortIds: cohortIds.length > 0 ? cohortIds : undefined } } }; @@ -386,7 +386,7 @@ async function createStudentOrgInvitesAndSendEmails(input: { roleName: 'Student', expiresAt, courseIds, - programIds + cohortIds } }; }) @@ -504,8 +504,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience } const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data); - const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data); - const accessNames = [...courseNames, ...programNames]; + const { cohortIds, cohortNames } = await resolveCohortIdsAndNamesForImport(orgId, data); + const accessNames = [...courseNames, ...cohortNames]; const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined; const assignedToCourses = await enrollAudienceStudentProfilesInCourses( @@ -515,11 +515,11 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience courseIds, data.sendEmail ); - const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms( + const assignedToCohorts = await enrollAudienceStudentProfilesInCohorts( organization, orgId, existingStudentProfileIds, - programIds, + cohortIds, data.sendEmail ); @@ -554,14 +554,14 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience } } - if (programIds.length > 0) { + if (cohortIds.length > 0) { const profiles = await getProfilesByEmails(newEmails); if (profiles.length > 0) { - await enrollAudienceStudentProfilesInPrograms( + await enrollAudienceStudentProfilesInCohorts( organization, orgId, profiles.map((profile) => profile.id), - programIds, + cohortIds, false ); } @@ -572,7 +572,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience organization, emails: newEmails, courseIds, - programIds, + cohortIds, accessNamesLabel, invitedByProfileId, shouldSendEmail: data.sendEmail @@ -590,7 +590,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience organization, emails: pendingStudentEmails, courseIds, - programIds, + cohortIds, accessNamesLabel, invitedByProfileId, shouldSendEmail: data.sendEmail @@ -601,12 +601,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience return { imported, - assigned: assignedToCourses.assigned + assignedToPrograms.assigned, + assigned: assignedToCourses.assigned + assignedToCohorts.assigned, alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled, - alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled, + alreadyEnrolledInCohorts: assignedToCohorts.alreadyEnrolled, pendingInvitesRenewed: pendingStudentEmails.length, duplicates: recipients.duplicates.length, - emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent, + emailsSent: assignedToCourses.emailsSent + assignedToCohorts.emailsSent + importEmailsSent + pendingEmailsSent, emailsFailed: importEmailsFailed + pendingEmailsFailed }; } @@ -628,9 +628,9 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB const emailToUse = member.email.toLowerCase().trim(); const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse); - const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {}; + const meta = (latestInvite?.metadata as { courseIds?: string[]; cohortIds?: string[] } | undefined) ?? {}; const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? []; - const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? []; + const cohortIdsFromMetadata = meta.cohortIds?.filter(Boolean) ?? []; let courseIds: string[] = []; let courseNames: string[] = []; @@ -640,19 +640,19 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB courseNames = courses.items.map((c) => c.title).filter(Boolean); } - let programIds: string[] = []; - let programNames: string[] = []; - if (programIdsFromMetadata.length > 0) { - const programs = await getProgramsByOrg(orgId, programIdsFromMetadata); - programIds = programs.map((program) => program.id); - programNames = programs.map((program) => program.name).filter(Boolean); + let cohortIds: string[] = []; + let cohortNames: string[] = []; + if (cohortIdsFromMetadata.length > 0) { + const cohorts = await getCohortsByOrg(orgId, cohortIdsFromMetadata); + cohortIds = cohorts.map((cohort) => cohort.id); + cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean); } await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId); const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString(); const token = generateToken(); - const accessNames = [...courseNames, ...programNames]; + const accessNames = [...courseNames, ...cohortNames]; const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined; const invite = await createOrganizationInvite({ @@ -666,7 +666,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB metadata: { source: 'AUDIENCE_RESEND', courseIds: courseIds.length > 0 ? courseIds : undefined, - programIds: programIds.length > 0 ? programIds : undefined + cohortIds: cohortIds.length > 0 ? cohortIds : undefined } }); @@ -684,7 +684,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB roleName: 'Student', expiresAt, courseIds, - programIds + cohortIds } } ]); @@ -788,10 +788,10 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien } const courseIds = data.courseIds ?? []; - const programIds = data.programIds ?? []; + const cohortIds = data.cohortIds ?? []; let assignedToCourses = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 }; - let assignedToPrograms = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 }; + let assignedToCohorts = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 }; if (courseIds.length > 0) { const courseGroups = await getOrgCourseGroups(orgId, courseIds); @@ -808,25 +808,25 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien ); } - if (programIds.length > 0) { - const programs = await getProgramsByOrg(orgId, programIds); - if (programs.length === 0) { - throw new AppError('No valid programs found', ErrorCodes.VALIDATION_ERROR, 400, 'programIds'); + if (cohortIds.length > 0) { + const cohorts = await getCohortsByOrg(orgId, cohortIds); + if (cohorts.length === 0) { + throw new AppError('No valid cohorts found', ErrorCodes.VALIDATION_ERROR, 400, 'cohortIds'); } - assignedToPrograms = await enrollAudienceStudentProfilesInPrograms( + assignedToCohorts = await enrollAudienceStudentProfilesInCohorts( organization, orgId, data.profileIds, - programIds, + cohortIds, data.sendEmail ); } return { - assigned: assignedToCourses.assigned + assignedToPrograms.assigned, - alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToPrograms.alreadyEnrolled, - emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + assigned: assignedToCourses.assigned + assignedToCohorts.assigned, + alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToCohorts.alreadyEnrolled, + emailsSent: assignedToCourses.emailsSent + assignedToCohorts.emailsSent }; } @@ -882,14 +882,14 @@ export async function updatePendingAudienceMemberEmail( } const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, currentEmail); - const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {}; + const meta = (latestInvite?.metadata as { courseIds?: string[]; cohortIds?: string[] } | undefined) ?? {}; await createStudentOrgInvitesAndSendEmails({ orgId, organization, emails: [normalizedEmail], courseIds: meta.courseIds?.filter(Boolean) ?? [], - programIds: meta.programIds?.filter(Boolean) ?? [], + cohortIds: meta.cohortIds?.filter(Boolean) ?? [], accessNamesLabel: undefined, invitedByProfileId, shouldSendEmail: data.sendEmail diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index 41646284..2c526a88 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -22,14 +22,14 @@ import { } from '@cio/db/queries/organization'; import { getCourseGroupIds } from '@cio/db/queries/course'; import { enrollUsersInCourseGroups } from '@cio/db/queries/group'; -import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program'; +import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort'; import { ROLE } from '@cio/utils/constants'; import type { TNewOrganizationInviteAudit } from '@db/types'; import crypto from 'node:crypto'; import { db, type DbOrTxClient } from '@cio/db/drizzle'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; -import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org'; +import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { buildEmailBranding } from '@cio/email'; @@ -404,7 +404,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c if (invite) { const metadata = invite.invite.metadata; const courseIds = parseCourseIdsFromInviteMetadata(metadata); - const programIds = parseProgramIdsFromInviteMetadata(metadata); + const cohortIds = parseCohortIdsFromInviteMetadata(metadata); if (courseIds.length > 0) { try { @@ -422,19 +422,19 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c } } - if (programIds.length > 0) { + if (cohortIds.length > 0) { try { - const existingProgramMemberships = await getExistingProgramMembers( - programIds.map((programId) => ({ programId, profileId: user.id })) + const existingCohortMemberships = await getExistingCohortMembers( + cohortIds.map((cohortId) => ({ cohortId, profileId: user.id })) ); - const programIdsToInsert = programIds.filter( - (programId) => !existingProgramMemberships.has(`${programId}:${user.id}`) + const cohortIdsToInsert = cohortIds.filter( + (cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`) ); await Promise.all( - programIdsToInsert.map((programId) => - addProgramMember({ - programId, + cohortIdsToInsert.map((cohortId) => + addCohortMember({ + cohortId, roleId: invite.invite.roleId, profileId: user.id, email: normalizedEmail @@ -442,7 +442,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c ) ); } catch (error) { - console.error('acceptOrganizationInvite program enrollment error:', error); + console.error('acceptOrganizationInvite cohort enrollment error:', error); } } } @@ -697,7 +697,7 @@ export async function acceptOrganizationInviteById( const metadata = result.invite.metadata; const courseIds = parseCourseIdsFromInviteMetadata(metadata); - const programIds = parseProgramIdsFromInviteMetadata(metadata); + const cohortIds = parseCohortIdsFromInviteMetadata(metadata); if (courseIds.length > 0) { try { @@ -714,22 +714,22 @@ export async function acceptOrganizationInviteById( } } - if (programIds.length > 0) { + if (cohortIds.length > 0) { try { - const existingProgramMemberships = await getExistingProgramMembers( - programIds.map((programId) => ({ programId, profileId: user.id })) + const existingCohortMemberships = await getExistingCohortMembers( + cohortIds.map((cohortId) => ({ cohortId, profileId: user.id })) ); - const programIdsToInsert = programIds.filter( - (programId) => !existingProgramMemberships.has(`${programId}:${user.id}`) + const cohortIdsToInsert = cohortIds.filter( + (cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`) ); await Promise.all( - programIdsToInsert.map((programId) => - addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail }) + cohortIdsToInsert.map((cohortId) => + addCohortMember({ cohortId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail }) ) ); } catch (error) { - console.error('acceptOrganizationInviteById program enrollment error:', error); + console.error('acceptOrganizationInviteById cohort enrollment error:', error); } } diff --git a/apps/api/src/services/organization/search.ts b/apps/api/src/services/organization/search.ts index 5a081c43..a7983a49 100644 --- a/apps/api/src/services/organization/search.ts +++ b/apps/api/src/services/organization/search.ts @@ -1,17 +1,17 @@ import { searchLmsCourses, - searchLmsPrograms, + searchLmsCohorts, searchOrgAudience, searchOrgCourses, - searchOrgPrograms, + searchOrgCohorts, searchOrgTagsAndGroups, searchOrgWidgets } from '@cio/db/queries'; export async function searchOrganization(orgId: string, search: string, limit: number) { - const [courses, programs, widgets, tags, audience] = await Promise.all([ + const [courses, cohorts, widgets, tags, audience] = await Promise.all([ searchOrgCourses(orgId, search, limit), - searchOrgPrograms(orgId, search, limit), + searchOrgCohorts(orgId, search, limit), searchOrgWidgets(orgId, search, limit), searchOrgTagsAndGroups(orgId, search, limit), searchOrgAudience(orgId, search, limit) @@ -19,7 +19,7 @@ export async function searchOrganization(orgId: string, search: string, limit: n return { courses, - programs, + cohorts, widgets, tags, audience @@ -27,13 +27,13 @@ export async function searchOrganization(orgId: string, search: string, limit: n } export async function searchLmsOrganization(orgId: string, profileId: string, search: string, limit: number) { - const [courses, programs] = await Promise.all([ + const [courses, cohorts] = await Promise.all([ searchLmsCourses(orgId, profileId, search, limit), - searchLmsPrograms(orgId, profileId, search, limit) + searchLmsCohorts(orgId, profileId, search, limit) ]); return { courses, - programs + cohorts }; } diff --git a/apps/api/src/services/program/invite.ts b/apps/api/src/services/program/invite.ts deleted file mode 100644 index 67524689..00000000 --- a/apps/api/src/services/program/invite.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { AppError, ErrorCodes } from '@api/utils/errors'; -import { getProgramById } from '@cio/db/queries/program'; -import type { TInviteStudentsToProgram, TAssignExistingStudentsToProgram } from '@cio/utils/validation/program'; -import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience'; - -/** - * Invite students to a single program by email CSV. - * - * Thin wrapper that hardcodes `programIds = [programId]` and delegates to - * `importAudienceMembers`. Lets a Program ADMIN/TUTOR (or org admin) issue org - * invites scoped to *their own* program, without granting org-wide invite rights. - */ -export async function inviteStudentsToProgram( - programId: string, - data: TInviteStudentsToProgram, - invitedByProfileId: string -) { - const program = await getProgramById(programId); - if (!program) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); - } - - return importAudienceMembers( - program.organizationId, - { - recipientCsv: data.recipientCsv, - programIds: [programId], - sendEmail: data.sendEmail, - allCourses: false, - allPrograms: false - }, - invitedByProfileId - ); -} - -/** - * Assign existing student profiles (already members of the org audience) to a - * single program. Same scoping logic as `inviteStudentsToProgram` — restricted - * to one program at a time so the caller's program-admin permission is enough. - */ -export async function assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram) { - const program = await getProgramById(programId); - if (!program) { - throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404); - } - - return assignAudienceToCourses(program.organizationId, { - profileIds: data.profileIds, - programIds: [programId], - sendEmail: data.sendEmail - }); -} diff --git a/apps/api/src/services/v1/audience.ts b/apps/api/src/services/v1/audience.ts index 8f2c7758..5ac0fdd6 100644 --- a/apps/api/src/services/v1/audience.ts +++ b/apps/api/src/services/v1/audience.ts @@ -29,7 +29,7 @@ export async function createAudienceService(orgId: string, actorId: string | nul recipientCsv: payload.email, courseIds: payload.courseIds, allCourses: false, - allPrograms: false, + allCohorts: false, sendEmail: payload.sendEmail }, actorId diff --git a/apps/api/src/utils/org.ts b/apps/api/src/utils/org.ts index ec71f0bd..f925a913 100644 --- a/apps/api/src/utils/org.ts +++ b/apps/api/src/utils/org.ts @@ -30,7 +30,7 @@ export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] { return parseInviteIds(metadata, ['courseIds', 'course_ids']); } -/** Normalize JSONB organization-invite metadata for program ids (matches importAudienceMembers storage). */ -export function parseProgramIdsFromInviteMetadata(metadata: unknown): string[] { - return parseInviteIds(metadata, ['programIds', 'program_ids']); +/** Normalize JSONB organization-invite metadata for cohort ids (legacy `programIds` supported). */ +export function parseCohortIdsFromInviteMetadata(metadata: unknown): string[] { + return parseInviteIds(metadata, ['cohortIds', 'cohort_ids', 'programIds', 'program_ids']); } diff --git a/apps/dashboard/src/lib/features/audience/pages/import.svelte b/apps/dashboard/src/lib/features/audience/pages/import.svelte index b9cf0662..a62e562e 100644 --- a/apps/dashboard/src/lib/features/audience/pages/import.svelte +++ b/apps/dashboard/src/lib/features/audience/pages/import.svelte @@ -16,23 +16,23 @@ title: string; } - interface Program { + interface Cohort { id: string; name: string; } interface Props { courses: Course[]; - programs: Program[]; + cohorts: Cohort[]; } - let { courses, programs }: Props = $props(); + let { courses, cohorts }: Props = $props(); let recipientCsv = $state(''); let courseAccessMode = $state('none'); - let programAccessMode = $state('none'); + let cohortAccessMode = $state('none'); let selectedCourseIds = new SvelteSet(); - let selectedProgramIds = new SvelteSet(); + let selectedCohortIds = new SvelteSet(); let sendEmail = $state(true); let isSubmitting = $state(false); @@ -44,11 +44,11 @@ } } - function toggleProgram(programId: string) { - if (selectedProgramIds.has(programId)) { - selectedProgramIds.delete(programId); + function toggleCohort(cohortId: string) { + if (selectedCohortIds.has(cohortId)) { + selectedCohortIds.delete(cohortId); } else { - selectedProgramIds.add(programId); + selectedCohortIds.add(cohortId); } } @@ -68,9 +68,9 @@ const result = await orgApi.importAudienceMembers({ recipientCsv, allCourses: courseAccessMode === 'all', - allPrograms: programAccessMode === 'all', + allCohorts: cohortAccessMode === 'all', courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined, - programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined, + cohortIds: cohortAccessMode === 'select' ? [...selectedCohortIds] : undefined, sendEmail }); @@ -128,32 +128,32 @@
- - + +
- - + +
- - + +
- - + +
- {#if programAccessMode === 'select'} + {#if cohortAccessMode === 'select'} ({ id: p.id, label: p.name || p.id }))} - isSelected={(id) => selectedProgramIds.has(id)} - onToggle={toggleProgram} - namePrefix="import-program" + heading={$t('audience.import.select_cohorts')} + emptyMessage={$t('audience.import.select_cohorts_placeholder')} + items={cohorts.map((p) => ({ id: p.id, label: p.name || p.id }))} + isSelected={(id) => selectedCohortIds.has(id)} + onToggle={toggleCohort} + namePrefix="import-cohort" /> {/if}
diff --git a/apps/dashboard/src/lib/features/cohort/api/cohort.svelte.ts b/apps/dashboard/src/lib/features/cohort/api/cohort.svelte.ts new file mode 100644 index 00000000..d1588b32 --- /dev/null +++ b/apps/dashboard/src/lib/features/cohort/api/cohort.svelte.ts @@ -0,0 +1,376 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import type { Cohort, CohortDetail, CohortMember, CohortCourse, CohortNewsfeed } from '../utils/types'; +import type { + TCreateCohort, + TUpdateCohort, + TAddCohortMembers, + TUpdateCohortMember, + TAddCourseToCohort, + TInviteStudentsToCohort, + TAssignExistingStudentsToCohort +} from '@cio/utils/validation/cohort'; +import { currentOrg } from '$lib/utils/store/org'; +import { get } from 'svelte/store'; +import { isStudentExperience } from '$lib/utils/store/app'; +import { snackbar } from '$features/ui/snackbar/store'; +import { t } from '$lib/utils/functions/translations'; + +class CohortApi extends BaseApiWithErrors { + cohorts = $state([]); + cohort = $state(null); + members = $state([]); + courses = $state([]); + newsfeed = $state(null); + isCohortShellLoading = $state(false); + currentCohortId = $state(null); + + loadedCohortId = $state(null); + loadedMembersCohortId = $state(null); + loadedCoursesCohortId = $state(null); + loadedCoursesStudentExperience = $state(null); + loadedNewsfeedCohortId = $state(null); + + syncCohortCourseCount(cohortId: string, courseCount: number) { + this.cohorts = this.cohorts.map((item) => (item.id === cohortId ? { ...item, courseCount } : item)); + } + + async listCohorts() { + const org = get(currentOrg); + if (!org.id) return; + + await this.execute({ + requestFn: () => classroomio.cohort.$get({ query: { organizationId: org.id } }), + onSuccess: (data) => { + this.cohorts = data.data; + }, + logContext: 'listCohorts' + }); + } + + async getCohort(cohortId: string, force = false) { + if (!force && this.loadedCohortId === cohortId) return; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['$get']>({ + requestFn: () => classroomio.cohort[':cohortId'].$get({ param: { cohortId } }), + onSuccess: (data) => { + this.cohort = data.data; + this.loadedCohortId = cohortId; + }, + logContext: 'getCohort' + }); + } + + resetCohortShell(cohortId?: string) { + this.currentCohortId = cohortId ?? null; + this.cohort = null; + this.members = []; + this.courses = []; + this.newsfeed = null; + this.loadedCohortId = null; + this.loadedMembersCohortId = null; + this.loadedCoursesCohortId = null; + this.loadedCoursesStudentExperience = null; + this.loadedNewsfeedCohortId = null; + } + + async ensureCohortShell(cohortId: string, force = false) { + const studentExperience = get(isStudentExperience); + const isShellReady = + this.loadedCohortId === cohortId && + this.loadedMembersCohortId === cohortId && + this.loadedCoursesCohortId === cohortId && + this.loadedCoursesStudentExperience === studentExperience; + + if (!force && isShellReady) { + return; + } + + const isCohortChanged = this.currentCohortId !== cohortId; + if (force || isCohortChanged) { + this.resetCohortShell(cohortId); + } + + this.currentCohortId = cohortId; + this.isCohortShellLoading = true; + + try { + await Promise.all([ + this.getCohort(cohortId, force), + this.listMembers(cohortId, force), + this.listCourses(cohortId, force) + ]); + } finally { + this.isCohortShellLoading = false; + } + } + + async createCohort(data: TCreateCohort) { + const org = get(currentOrg); + if (!org.id) return; + + await this.execute({ + requestFn: () => + classroomio.cohort.$post({ + json: { ...data, organizationId: org.id } + }), + onSuccess: (res) => { + this.cohorts = [res.data, ...this.cohorts]; + snackbar.success(t.get('cohorts.create_success')); + }, + logContext: 'createCohort' + }); + } + + async updateCohort(cohortId: string, data: TUpdateCohort) { + await this.execute<(typeof classroomio.cohort)[':cohortId']['$put']>({ + requestFn: () => classroomio.cohort[':cohortId'].$put({ param: { cohortId }, json: data }), + onSuccess: (res) => { + this.cohort = res.data; + this.cohorts = this.cohorts.map((p) => (p.id === cohortId ? { ...p, ...res.data } : p)); + snackbar.success(t.get('cohorts.update_success')); + }, + onError: (result) => { + const message = typeof result === 'string' ? result : result.error; + snackbar.error(message || 'Failed to update cohort'); + }, + logContext: 'updateCohort' + }); + } + + async deleteCohort(cohortId: string) { + await this.execute<(typeof classroomio.cohort)[':cohortId']['$delete']>({ + requestFn: () => classroomio.cohort[':cohortId'].$delete({ param: { cohortId } }), + onSuccess: () => { + this.cohorts = this.cohorts.filter((p) => p.id !== cohortId); + snackbar.success(t.get('cohorts.delete_success')); + }, + logContext: 'deleteCohort' + }); + } + + // Members + + async listMembers(cohortId: string, force = false) { + if (!force && this.loadedMembersCohortId === cohortId) return; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$get']>({ + requestFn: () => classroomio.cohort[':cohortId'].members.$get({ param: { cohortId } }), + onSuccess: (data) => { + this.members = data.data; + this.loadedMembersCohortId = cohortId; + }, + logContext: 'listCohortMembers' + }); + } + + async addMembers(cohortId: string, data: TAddCohortMembers) { + await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$post']>({ + requestFn: () => classroomio.cohort[':cohortId'].members.$post({ param: { cohortId }, json: data }), + onSuccess: async () => { + await this.listMembers(cohortId, true); + snackbar.success(t.get('cohorts.members_added') || 'Members added'); + }, + logContext: 'addCohortMembers' + }); + } + + async updateMember(cohortId: string, memberId: string, data: TUpdateCohortMember) { + await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$put']>({ + requestFn: () => + classroomio.cohort[':cohortId'].members[':memberId'].$put({ + param: { cohortId, memberId }, + json: data + }), + onSuccess: (res) => { + this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m)); + }, + logContext: 'updateCohortMember' + }); + } + + async removeMember(cohortId: string, memberId: string) { + await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$delete']>({ + requestFn: () => + classroomio.cohort[':cohortId'].members[':memberId'].$delete({ + param: { cohortId, memberId } + }), + onSuccess: () => { + this.members = this.members.filter((m) => m.id !== memberId); + snackbar.success(t.get('cohorts.member_removed') || 'Member removed'); + }, + logContext: 'removeCohortMember' + }); + } + + // Courses + + async listCourses(cohortId: string, force = false) { + const studentExperience = get(isStudentExperience); + + if ( + !force && + this.loadedCoursesCohortId === cohortId && + this.loadedCoursesStudentExperience === studentExperience + ) { + return; + } + + await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$get']>({ + requestFn: () => classroomio.cohort[':cohortId'].courses.$get({ param: { cohortId } }), + onSuccess: (data) => { + this.courses = data.data; + this.syncCohortCourseCount(cohortId, data.data.length); + this.loadedCoursesCohortId = cohortId; + this.loadedCoursesStudentExperience = studentExperience; + }, + logContext: 'listCohortCourses' + }); + } + + async addCourse(cohortId: string, data: TAddCourseToCohort) { + await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$post']>({ + requestFn: () => classroomio.cohort[':cohortId'].courses.$post({ param: { cohortId }, json: data }), + onSuccess: async () => { + await this.listCourses(cohortId, true); + this.syncCohortCourseCount(cohortId, this.courses.length); + snackbar.success(t.get('cohorts.course_added') || 'Course added'); + }, + logContext: 'addCourseToCohort' + }); + } + + async addCourses(cohortId: string, courseIds: string[]) { + if (courseIds.length === 0) { + return false; + } + + this.isLoading = true; + this.error = null; + this.success = false; + + try { + for (const courseId of courseIds) { + const response = await classroomio.cohort[':cohortId'].courses.$post({ + param: { cohortId }, + json: { courseId } + }); + const result = (await response.json()) as { success?: boolean; error?: string }; + + if (!result.success) { + this.error = result.error ?? 'Failed to add course to cohort'; + return false; + } + } + + await this.listCourses(cohortId, true); + this.syncCohortCourseCount(cohortId, this.courses.length); + + const successMessage = + courseIds.length === 1 + ? t.get('cohorts.course_added') || 'Course added' + : t.get('cohorts.multiple_added') || 'Courses added'; + + snackbar.success(successMessage); + this.success = true; + return true; + } catch (error) { + console.error('Error in addCoursesToCohort:', error); + this.error = error instanceof Error ? error.message : 'Failed to add courses to cohort'; + return false; + } finally { + this.isLoading = false; + } + } + + async removeCourse(cohortId: string, courseId: string) { + await this.execute<(typeof classroomio.cohort)[':cohortId']['courses'][':courseId']['$delete']>({ + requestFn: () => + classroomio.cohort[':cohortId'].courses[':courseId'].$delete({ + param: { cohortId, courseId } + }), + onSuccess: () => { + this.courses = this.courses.filter((c) => c.courseId !== courseId); + this.syncCohortCourseCount(cohortId, this.courses.length); + snackbar.success(t.get('cohorts.course_removed') || 'Course removed'); + }, + logContext: 'removeCourseFromCohort' + }); + } + + // Invitations (program-scoped) + + /** + * Invite new students to a cohort by CSV. Goes through the cohort-scoped + * endpoint so a Cohort ADMIN/TUTOR can invite without org-admin rights. + * Returns whether the call succeeded so callers can refresh their lists. + */ + async inviteStudentsToCohort(cohortId: string, data: TInviteStudentsToCohort): Promise { + let ok = false; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['$post']>({ + requestFn: () => classroomio.cohort[':cohortId'].invite.$post({ param: { cohortId }, json: data }), + onSuccess: async () => { + await this.listMembers(cohortId, true); + snackbar.success(t.get('cohorts.invite_sent') || 'Invites sent'); + ok = true; + }, + logContext: 'inviteStudentsToCohort' + }); + + return ok; + } + + /** + * Assign existing org audience student profiles to this cohort. + * Same scoping as `inviteStudentsToCohort` — caller only needs program-team + * membership for this cohort. + */ + async assignExistingStudentsToCohort(cohortId: string, data: TAssignExistingStudentsToCohort): Promise { + let ok = false; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['assign']['$post']>({ + requestFn: () => classroomio.cohort[':cohortId'].invite.assign.$post({ param: { cohortId }, json: data }), + onSuccess: async () => { + await this.listMembers(cohortId, true); + snackbar.success(t.get('cohorts.members_added') || 'Members added'); + ok = true; + }, + logContext: 'assignExistingStudentsToCohort' + }); + + return ok; + } + + // Newsfeed + + async listNewsfeed(cohortId: string, force = false, cursor?: string) { + if (!force && this.loadedNewsfeedCohortId === cohortId) return; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['newsfeed']['$get']>({ + requestFn: () => + classroomio.cohort[':cohortId'].newsfeed.$get({ + param: { cohortId }, + query: { limit: '10', ...(cursor ? { cursor } : {}) } + }), + onSuccess: (data) => { + this.newsfeed = data.data; + this.loadedNewsfeedCohortId = cohortId; + }, + logContext: 'listCohortNewsfeed' + }); + } + + // LMS — enrolled cohorts + + async listEnrolledCohorts() { + await this.execute({ + requestFn: () => classroomio.cohort.enrolled.$get({}), + onSuccess: (data) => { + this.cohorts = data.data; + }, + logContext: 'listEnrolledCohorts' + }); + } +} + +export const cohortApi = new CohortApi(); diff --git a/apps/dashboard/src/lib/features/cohort/api/goal.svelte.ts b/apps/dashboard/src/lib/features/cohort/api/goal.svelte.ts new file mode 100644 index 00000000..1d72f2a1 --- /dev/null +++ b/apps/dashboard/src/lib/features/cohort/api/goal.svelte.ts @@ -0,0 +1,129 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import type { TCreateCohortGoal, TUpdateCohortGoal } from '@cio/utils/validation/cohort'; +import type { GoalsOverviewRow, MyGoalAssignment, CohortGoal } from '../utils/types'; +import { snackbar } from '$features/ui/snackbar/store'; +import { t } from '$lib/utils/functions/translations'; +import { currentOrg } from '$lib/utils/store/org'; +import { get } from 'svelte/store'; + +class CohortGoalApi extends BaseApiWithErrors { + goalsByCohortId = $state>({}); + myGoals = $state([]); + overviewRows = $state([]); + loadedCohortId = $state(null); + + goalsFor(cohortId: string): CohortGoal[] { + return this.goalsByCohortId[cohortId] ?? []; + } + + async listGoals(cohortId: string, force = false) { + if (!force && this.goalsByCohortId[cohortId] && this.loadedCohortId === cohortId) return; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$get']>({ + requestFn: () => classroomio.cohort[':cohortId'].goals.$get({ param: { cohortId } }), + onSuccess: (res) => { + this.goalsByCohortId = { ...this.goalsByCohortId, [cohortId]: res.data }; + this.loadedCohortId = cohortId; + }, + logContext: 'listCohortGoals' + }); + } + + async createGoal(cohortId: string, data: TCreateCohortGoal): Promise { + let ok = false; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$post']>({ + requestFn: () => classroomio.cohort[':cohortId'].goals.$post({ param: { cohortId }, json: data }), + onSuccess: async () => { + await this.listGoals(cohortId, true); + snackbar.success(t.get('cohorts.goals.created') || 'Goal created'); + ok = true; + }, + logContext: 'createCohortGoal' + }); + + return ok; + } + + async updateGoal(cohortId: string, goalId: string, data: TUpdateCohortGoal): Promise { + let ok = false; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$put']>({ + requestFn: () => + classroomio.cohort[':cohortId'].goals[':goalId'].$put({ + param: { cohortId, goalId }, + json: data + }), + onSuccess: async () => { + await this.listGoals(cohortId, true); + snackbar.success(t.get('cohorts.goals.updated') || 'Goal updated'); + ok = true; + }, + logContext: 'updateCohortGoal' + }); + + return ok; + } + + async deleteGoal(cohortId: string, goalId: string): Promise { + let ok = false; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$delete']>({ + requestFn: () => + classroomio.cohort[':cohortId'].goals[':goalId'].$delete({ + param: { cohortId, goalId } + }), + onSuccess: async () => { + await this.listGoals(cohortId, true); + snackbar.success(t.get('cohorts.goals.deleted') || 'Goal deleted'); + ok = true; + }, + logContext: 'deleteCohortGoal' + }); + + return ok; + } + + async evaluateGoal(cohortId: string, goalId: string): Promise { + let ok = false; + + await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['evaluate']['$post']>({ + requestFn: () => + classroomio.cohort[':cohortId'].goals[':goalId'].evaluate.$post({ + param: { cohortId, goalId } + }), + onSuccess: async () => { + await this.listGoals(cohortId, true); + ok = true; + }, + logContext: 'evaluateCohortGoal' + }); + + return ok; + } + + async listMyGoals() { + await this.execute({ + requestFn: () => classroomio.cohort.my.goals.$get({}), + onSuccess: (res) => { + this.myGoals = res.data; + }, + logContext: 'listMyGoals' + }); + } + + async listOverview() { + const org = get(currentOrg); + if (!org.id) return; + + await this.execute({ + requestFn: () => classroomio.cohort.goals.overview.$get({ query: { organizationId: org.id } }), + onSuccess: (res) => { + this.overviewRows = res.data.goals; + }, + logContext: 'listGoalsOverview' + }); + } +} + +export const cohortGoalApi = new CohortGoalApi(); diff --git a/apps/dashboard/src/lib/features/cohort/api/index.ts b/apps/dashboard/src/lib/features/cohort/api/index.ts new file mode 100644 index 00000000..b149bd05 --- /dev/null +++ b/apps/dashboard/src/lib/features/cohort/api/index.ts @@ -0,0 +1,3 @@ +export { cohortApi } from './cohort.svelte'; +export { cohortGoalApi } from './goal.svelte'; +export { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from './newsfeed.svelte'; diff --git a/apps/dashboard/src/lib/features/program/api/newsfeed.svelte.ts b/apps/dashboard/src/lib/features/cohort/api/newsfeed.svelte.ts similarity index 62% rename from apps/dashboard/src/lib/features/program/api/newsfeed.svelte.ts rename to apps/dashboard/src/lib/features/cohort/api/newsfeed.svelte.ts index 5dfad2de..549e4651 100644 --- a/apps/dashboard/src/lib/features/program/api/newsfeed.svelte.ts +++ b/apps/dashboard/src/lib/features/cohort/api/newsfeed.svelte.ts @@ -1,35 +1,35 @@ import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; import type { - CreateProgramNewsfeedCommentRequest, - CreateProgramNewsfeedRequest, - DeleteProgramNewsfeedCommentRequest, - DeleteProgramNewsfeedRequest, - GetProgramNewsfeedCommentsRequest, - ListProgramNewsfeed, - ListProgramNewsfeedRequest, + CreateCohortNewsfeedCommentRequest, + CreateCohortNewsfeedRequest, + DeleteCohortNewsfeedCommentRequest, + DeleteCohortNewsfeedRequest, + GetCohortNewsfeedCommentsRequest, + ListCohortNewsfeed, + ListCohortNewsfeedRequest, ProgramNewsfeedComment, - ReactToProgramNewsfeedRequest, - UpdateProgramNewsfeedRequest + ReactToCohortNewsfeedRequest, + UpdateCohortNewsfeedRequest } from '../utils/types'; import type { - TCreateProgramNewsfeed, - TCreateProgramNewsfeedComment, - TUpdateProgramNewsfeed, - TUpdateProgramReaction -} from '@cio/utils/validation/program'; + TCreateCohortNewsfeed, + TCreateCohortNewsfeedComment, + TUpdateCohortNewsfeed, + TUpdateCohortReaction +} from '@cio/utils/validation/cohort'; import { - ZCreateProgramNewsfeed, - ZCreateProgramNewsfeedComment, - ZUpdateProgramNewsfeed, - ZUpdateProgramReaction -} from '@cio/utils/validation/program'; + ZCreateCohortNewsfeed, + ZCreateCohortNewsfeedComment, + ZUpdateCohortNewsfeed, + ZUpdateCohortReaction +} from '@cio/utils/validation/cohort'; import { get } from 'svelte/store'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { profile } from '$lib/utils/store/user'; import { snackbar } from '$features/ui/snackbar/store'; -export type ProgramNewsfeedCommentsByFeedId = { +export type CohortNewsfeedCommentsByFeedId = { items: ProgramNewsfeedComment[]; totalCount: number; hasMore: boolean; @@ -37,10 +37,10 @@ export type ProgramNewsfeedCommentsByFeedId = { cursor: string | null; }; -export class ProgramNewsfeedApi extends BaseApiWithErrors { - feeds = $state([]); +export class CohortNewsfeedApi extends BaseApiWithErrors { + feeds = $state([]); - commentsByFeedId = $state>({}); + commentsByFeedId = $state>({}); isNewFeedModalOpen = $state(false); @@ -52,14 +52,14 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { this.isNewFeedModalOpen = false; } - async list(programId: string) { - await this.execute({ + async list(cohortId: string) { + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed.$get({ - param: { programId }, + classroomio.cohort[':cohortId'].newsfeed.$get({ + param: { cohortId }, query: {} }), - logContext: 'listing program newsfeed', + logContext: 'listing cohort newsfeed', onSuccess: (response) => { this.feeds = response.data.items || []; this.commentsByFeedId = {}; @@ -69,36 +69,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { } }, onError: () => { - snackbar.error('snackbar.program.error.news_feed_fail'); + snackbar.error('snackbar.cohort.error.news_feed_fail'); } }); } - async create(programId: string, fields: TCreateProgramNewsfeed) { + async create(cohortId: string, fields: TCreateCohortNewsfeed) { this.success = false; if (isHtmlValueEmpty(fields.content)) { this.errors = mapZodErrorsToTranslations( - ZCreateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!, + ZCreateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!, 'newsfeed' ); return; } - const validationResult = ZCreateProgramNewsfeed.safeParse(fields); + const validationResult = ZCreateCohortNewsfeed.safeParse(fields); if (!validationResult.success) { this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed'); return; } - await this.execute({ + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed.$post({ - param: { programId }, + classroomio.cohort[':cohortId'].newsfeed.$post({ + param: { cohortId }, json: fields }), - logContext: 'creating program newsfeed', + logContext: 'creating cohort newsfeed', onSuccess: (response) => { const author = get(profile); const newFeed = { @@ -112,36 +112,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { this.feeds = [newFeed, ...this.feeds]; this.initComments(newFeed.id, 0); - snackbar.success('snackbar.program.success.post_created'); + snackbar.success('snackbar.cohort.success.post_created'); } }); } - async update(programId: string, feedId: string, fields: TUpdateProgramNewsfeed) { + async update(cohortId: string, feedId: string, fields: TUpdateCohortNewsfeed) { this.success = false; if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) { this.errors = mapZodErrorsToTranslations( - ZUpdateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!, + ZUpdateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!, 'newsfeed' ); return; } - const validationResult = ZUpdateProgramNewsfeed.safeParse(fields); + const validationResult = ZUpdateCohortNewsfeed.safeParse(fields); if (!validationResult.success) { this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed'); return; } - await this.execute({ + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed[':feedId'].$put({ - param: { programId, feedId }, + classroomio.cohort[':cohortId'].newsfeed[':feedId'].$put({ + param: { cohortId, feedId }, json: fields }), - logContext: 'updating program newsfeed', + logContext: 'updating cohort newsfeed', onSuccess: (response) => { const index = this.feeds.findIndex((feed) => feed.id === feedId); if (index === -1) { @@ -159,26 +159,26 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { commentCount: existingFeed.commentCount }; - snackbar.success('snackbar.program.success.post_updated'); + snackbar.success('snackbar.cohort.success.post_updated'); } }); } - async react(programId: string, feedId: string, reaction: TUpdateProgramReaction['reaction']) { - const validationResult = ZUpdateProgramReaction.safeParse({ reaction }); + async react(cohortId: string, feedId: string, reaction: TUpdateCohortReaction['reaction']) { + const validationResult = ZUpdateCohortReaction.safeParse({ reaction }); if (!validationResult.success) { this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed'); return; } - await this.execute({ + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed[':feedId'].react.$put({ - param: { programId, feedId }, + classroomio.cohort[':cohortId'].newsfeed[':feedId'].react.$put({ + param: { cohortId, feedId }, json: validationResult.data }), - logContext: 'reacting to program newsfeed', + logContext: 'reacting to cohort newsfeed', onSuccess: (response) => { const index = this.feeds.findIndex((feed) => feed.id === feedId); if (index === -1) { @@ -197,25 +197,25 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { }; }, onError: () => { - snackbar.error('snackbar.program.error.reaction_error'); + snackbar.error('snackbar.cohort.error.reaction_error'); } }); } - async delete(programId: string, feedId: string) { - await this.execute({ + async delete(cohortId: string, feedId: string) { + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed[':feedId'].$delete({ - param: { programId, feedId } + classroomio.cohort[':cohortId'].newsfeed[':feedId'].$delete({ + param: { cohortId, feedId } }), - logContext: 'deleting program newsfeed', + logContext: 'deleting cohort newsfeed', onSuccess: () => { this.feeds = this.feeds.filter((feed) => feed.id !== feedId); const { [feedId]: _removedComments, ...remainingComments } = this.commentsByFeedId; this.commentsByFeedId = remainingComments; - snackbar.success('snackbar.program.success.feed_delete_success'); + snackbar.success('snackbar.cohort.success.feed_delete_success'); } }); } @@ -235,19 +235,19 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { this.commentsByFeedId[feedId].totalCount = totalCount; } - async getComments(programId: string, feedId: string) { + async getComments(cohortId: string, feedId: string) { if (!this.commentsByFeedId[feedId]) { this.initComments(feedId, 0); } this.commentsByFeedId[feedId].isLoading = true; - await this.execute({ + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed[':feedId'].comments.$get({ - param: { programId, feedId } + classroomio.cohort[':cohortId'].newsfeed[':feedId'].comments.$get({ + param: { cohortId, feedId } }), - logContext: 'fetching program newsfeed comments', + logContext: 'fetching cohort newsfeed comments', onSuccess: (response) => { this.commentsByFeedId[feedId] = { items: response.data, @@ -264,27 +264,27 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { } async createComment( - programId: string, + cohortId: string, feedId: string, content: string, author: { id: string; username: string; fullname: string; avatarUrl: string } ) { - const validationResult = ZCreateProgramNewsfeedComment.safeParse({ + const validationResult = ZCreateCohortNewsfeedComment.safeParse({ content: getTextFromHTML(content) - } satisfies TCreateProgramNewsfeedComment); + } satisfies TCreateCohortNewsfeedComment); if (!validationResult.success) { this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed'); return; } - await this.execute({ + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed[':feedId'].comment.$post({ - param: { programId, feedId }, + classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment.$post({ + param: { cohortId, feedId }, json: { content } }), - logContext: 'creating program newsfeed comment', + logContext: 'creating cohort newsfeed comment', onSuccess: (response) => { const existingCommentState = this.commentsByFeedId[feedId]; if (!existingCommentState) { @@ -313,18 +313,18 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { }; } - snackbar.success('snackbar.program.success.comment_added'); + snackbar.success('snackbar.cohort.success.comment_added'); } }); } - async deleteComment(programId: string, feedId: string, commentId: string) { - await this.execute({ + async deleteComment(cohortId: string, feedId: string, commentId: string) { + await this.execute({ requestFn: () => - classroomio.program[':programId'].newsfeed[':feedId'].comment[':commentId'].$delete({ - param: { programId, feedId, commentId } + classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment[':commentId'].$delete({ + param: { cohortId, feedId, commentId } }), - logContext: 'deleting program newsfeed comment', + logContext: 'deleting cohort newsfeed comment', onSuccess: () => { const existingCommentState = this.commentsByFeedId[feedId]; if (!existingCommentState) { @@ -345,10 +345,10 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors { }; } - snackbar.success('snackbar.program.success.comment_deleted'); + snackbar.success('snackbar.cohort.success.comment_deleted'); } }); } } -export const programNewsfeedApi = new ProgramNewsfeedApi(); +export const cohortNewsfeedApi = new CohortNewsfeedApi(); diff --git a/apps/dashboard/src/lib/features/program/components/add-course-to-program-modal.svelte b/apps/dashboard/src/lib/features/cohort/components/add-course-to-cohort-modal.svelte similarity index 81% rename from apps/dashboard/src/lib/features/program/components/add-course-to-program-modal.svelte rename to apps/dashboard/src/lib/features/cohort/components/add-course-to-cohort-modal.svelte index e87f68fb..ca61b87f 100644 --- a/apps/dashboard/src/lib/features/program/components/add-course-to-program-modal.svelte +++ b/apps/dashboard/src/lib/features/cohort/components/add-course-to-cohort-modal.svelte @@ -6,24 +6,24 @@ import { Button } from '@cio/ui/base/button'; import { MultiSelectList } from '@cio/ui/custom/multi-select-list'; import { t } from '$lib/utils/functions/translations'; - import { programApi } from '../api'; + import { cohortApi } from '../api'; import { coursesApi } from '$features/course/api'; interface Props { open: boolean; - programId: string; + cohortId: string; } const COURSE_PAGE_SIZE = 20; - let { open = $bindable(false), programId }: Props = $props(); + let { open = $bindable(false), cohortId }: Props = $props(); let searchValue = $state(''); let currentPage = $state(1); const selectedCourseIds = new SvelteSet(); const availableCourses = $derived.by(() => { - const existingCourseIds = new Set(programApi.courses.map((course) => course.courseId)); + const existingCourseIds = new Set(cohortApi.courses.map((course) => course.courseId)); return coursesApi.orgCourses.filter((course) => !existingCourseIds.has(course.id)); }); @@ -47,8 +47,8 @@ const canGoToNextPage = $derived(currentPage < totalPages); const emptyMessage = $derived( searchValue.trim() - ? $t('programs.courses.no_matching_courses') || 'No courses match your search.' - : $t('programs.courses.no_available_courses') || 'No available courses to add.' + ? $t('cohorts.courses.no_matching_courses') || 'No courses match your search.' + : $t('cohorts.courses.no_available_courses') || 'No available courses to add.' ); const multiSelectItems = $derived( paginatedCourses.map((course) => ({ @@ -97,7 +97,7 @@ return; } - const didAddCourses = await programApi.addCourses(programId, courseIds); + const didAddCourses = await cohortApi.addCourses(cohortId, courseIds); if (!didAddCourses) { return; } @@ -127,7 +127,7 @@ - {$t('programs.courses.add_modal_title') || 'Add Course to Program'} + {$t('cohorts.courses.add_modal_title') || 'Add Course to Cohort'}
@@ -139,14 +139,14 @@ isSelected={(id) => selectedCourseIds.has(id)} onSearchValueChange={handleSearchValueChange} onToggle={(id) => toggleCourse(id)} - namePrefix="program-course" + namePrefix="cohort-course" listClass="max-h-60" - searchPlaceholder={$t('programs.courses.search_placeholder') || 'Search courses'} + searchPlaceholder={$t('cohorts.courses.search_placeholder') || 'Search courses'} bind:searchValue > {#snippet headingSnippet()}

- {$t('programs.courses.select_courses') || $t('programs.courses.select_course') || 'Select course(s)'} + {$t('cohorts.courses.select_courses') || $t('cohorts.courses.select_course') || 'Select course(s)'} {#if selectedCount > 0} {$t('audience.selected_count', { count: selectedCount })} @@ -159,7 +159,7 @@ {#if totalPages > 1}

- {$t('programs.courses.pagination_status', { + {$t('cohorts.courses.pagination_status', { page: currentPage, totalPages }) || `Page ${currentPage} of ${totalPages}`} @@ -188,11 +188,11 @@
- - diff --git a/apps/dashboard/src/lib/features/program/components/program-card.svelte b/apps/dashboard/src/lib/features/cohort/components/cohort-card.svelte similarity index 70% rename from apps/dashboard/src/lib/features/program/components/program-card.svelte rename to apps/dashboard/src/lib/features/cohort/components/cohort-card.svelte index 2786e9cc..dc6aefb7 100644 --- a/apps/dashboard/src/lib/features/program/components/program-card.svelte +++ b/apps/dashboard/src/lib/features/cohort/components/cohort-card.svelte @@ -3,17 +3,17 @@ import * as Item from '@cio/ui/base/item'; import { Separator } from '@cio/ui/base/separator'; import { t } from '$lib/utils/functions/translations'; - import type { Program } from '../utils/types'; + import type { Cohort } from '../utils/types'; interface Props { - program: Program; + cohort: Cohort; onclick?: () => void; } - let { program, onclick }: Props = $props(); + let { cohort, onclick }: Props = $props(); - const courseCount = $derived(program.courseCount ?? 0); - const studentCount = $derived(program.studentCount ?? 0); + const courseCount = $derived(cohort.courseCount ?? 0); + const studentCount = $derived(cohort.studentCount ?? 0); @@ -21,7 +21,7 @@
- {program.name} + {cohort.name} @@ -29,13 +29,13 @@

- {pluralize($t('programs.card.courses'), courseCount, true)} + {pluralize($t('cohorts.card.courses'), courseCount, true)}

- {pluralize($t('programs.card.students'), studentCount, true)} + {pluralize($t('cohorts.card.students'), studentCount, true)}

diff --git a/apps/dashboard/src/lib/features/program/components/program-filter-popover.svelte b/apps/dashboard/src/lib/features/cohort/components/cohort-filter-popover.svelte similarity index 78% rename from apps/dashboard/src/lib/features/program/components/program-filter-popover.svelte rename to apps/dashboard/src/lib/features/cohort/components/cohort-filter-popover.svelte index c23365e6..327e19c9 100644 --- a/apps/dashboard/src/lib/features/program/components/program-filter-popover.svelte +++ b/apps/dashboard/src/lib/features/cohort/components/cohort-filter-popover.svelte @@ -7,10 +7,10 @@ import FilterIcon from '@lucide/svelte/icons/filter'; import { t } from '$lib/utils/functions/translations'; import { - DEFAULT_PROGRAM_SORT, - DEFAULT_PROGRAM_SORT_ORDER, - type ProgramSortBy, - type ProgramSortOrder + DEFAULT_COHORT_SORT, + DEFAULT_COHORT_SORT_ORDER, + type CohortSortBy, + type CohortSortOrder } from '../utils/constants'; interface StatusOption { @@ -19,13 +19,13 @@ } interface SortOption { - value: ProgramSortBy; + value: CohortSortBy; label: string; } interface Props { - sortKey?: ProgramSortBy; - selectedOrder?: ProgramSortOrder; + sortKey?: CohortSortBy; + selectedOrder?: CohortSortOrder; selectedStatuses?: string[]; statusOptions?: StatusOption[]; sortOptions?: SortOption[]; @@ -34,8 +34,8 @@ } let { - sortKey = $bindable(DEFAULT_PROGRAM_SORT), - selectedOrder = $bindable(DEFAULT_PROGRAM_SORT_ORDER), + sortKey = $bindable(DEFAULT_COHORT_SORT), + selectedOrder = $bindable(DEFAULT_COHORT_SORT_ORDER), selectedStatuses = [], statusOptions = [], sortOptions = [], @@ -46,7 +46,7 @@ let isOpen = $state(false); const hasActiveFilters = $derived( - sortKey !== DEFAULT_PROGRAM_SORT || selectedOrder !== DEFAULT_PROGRAM_SORT_ORDER || selectedStatuses.length > 0 + sortKey !== DEFAULT_COHORT_SORT || selectedOrder !== DEFAULT_COHORT_SORT_ORDER || selectedStatuses.length > 0 ); function isStatusSelected(status: string) { @@ -58,7 +58,7 @@ {#snippet child({ props })}
- + {#if hasActiveFilters} @@ -74,15 +74,15 @@
-

{$t('programs.filters.popover_title')}

+

{$t('cohorts.filters.popover_title')}

- {$t('programs.filters.sort')} + {$t('cohorts.filters.sort')}

{#each sortOptions as option (option.value)} @@ -100,7 +100,7 @@

- {$t('programs.filters.order')} + {$t('cohorts.filters.order')}

- {$t('programs.filters.ascending')} - {$t('programs.filters.descending')} + {$t('cohorts.filters.ascending')} + {$t('cohorts.filters.descending')}

- {$t('programs.filters.status')} + {$t('cohorts.filters.status')}

{#each statusOptions as option (option.value)} diff --git a/apps/dashboard/src/lib/features/program/components/program-header.svelte b/apps/dashboard/src/lib/features/cohort/components/cohort-header.svelte similarity index 89% rename from apps/dashboard/src/lib/features/program/components/program-header.svelte rename to apps/dashboard/src/lib/features/cohort/components/cohort-header.svelte index cb44c422..dc0a7d09 100644 --- a/apps/dashboard/src/lib/features/program/components/program-header.svelte +++ b/apps/dashboard/src/lib/features/cohort/components/cohort-header.svelte @@ -1,7 +1,7 @@

- {programApi.program?.name || ''} + {cohortApi.cohort?.name || ''}

diff --git a/apps/dashboard/src/lib/features/program/components/create-program-modal.svelte b/apps/dashboard/src/lib/features/cohort/components/create-cohort-modal.svelte similarity index 59% rename from apps/dashboard/src/lib/features/program/components/create-program-modal.svelte rename to apps/dashboard/src/lib/features/cohort/components/create-cohort-modal.svelte index ae4817f4..b76388e9 100644 --- a/apps/dashboard/src/lib/features/program/components/create-program-modal.svelte +++ b/apps/dashboard/src/lib/features/cohort/components/create-cohort-modal.svelte @@ -5,7 +5,7 @@ import { Textarea } from '@cio/ui/base/textarea'; import { Label } from '@cio/ui/base/label'; import { t } from '$lib/utils/functions/translations'; - import { programApi } from '../api'; + import { cohortApi } from '../api'; interface Props { open: boolean; @@ -18,7 +18,7 @@ async function handleCreate() { if (!name.trim()) return; - await programApi.createProgram({ name: name.trim(), description: description.trim() || undefined }); + await cohortApi.createCohort({ name: name.trim(), description: description.trim() || undefined }); name = ''; description = ''; open = false; @@ -34,29 +34,29 @@ - {$t('programs.create_modal.title') || 'Create Program'} + {$t('cohorts.create_modal.title') || 'Create Cohort'} - {$t('programs.create_modal.description') || 'Add a new certification or compliance program.'} + {$t('cohorts.create_modal.description') || 'Add a new certification or compliance cohort.'}
- +
- +