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 <[email protected]>

* fix(db): restore migrate:answer-data script alongside cohort migration

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): cast program enums when migrating data to cohort tables

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): truncate cohort tables before program-to-cohort copy

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): report accurate row counts in programs-to-cohorts migration

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-02 12:10:31 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent fe9f8c441f
commit ec61bfe7d9
115 changed files with 19703 additions and 6046 deletions
@@ -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<string>();
let selectedProgramIds = new SvelteSet<string>();
let selectedCohortIds = new SvelteSet<string>();
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 @@
</div>
<div class="space-y-3">
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
<Label class="text-sm font-medium">{$t('audience.import.cohort_access')}</Label>
<RadioGroup.Root bind:value={cohortAccessMode} class="space-y-2">
<div class="flex items-center gap-2">
<RadioGroup.Item value="none" id="program-none" />
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
<RadioGroup.Item value="none" id="cohort-none" />
<Label for="cohort-none" class="font-normal">{$t('audience.import.no_cohorts')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="all" id="program-all" />
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
<RadioGroup.Item value="all" id="cohort-all" />
<Label for="cohort-all" class="font-normal">{$t('audience.import.all_cohorts')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="select" id="program-select" />
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
<RadioGroup.Item value="select" id="cohort-select" />
<Label for="cohort-select" class="font-normal">{$t('audience.import.select_cohorts')}</Label>
</div>
</RadioGroup.Root>
{#if programAccessMode === 'select'}
{#if cohortAccessMode === 'select'}
<MultiSelectList
class="ml-6"
listClass="max-h-40"
heading={$t('audience.import.select_programs')}
emptyMessage={$t('audience.import.select_programs_placeholder')}
items={programs.map((p) => ({ 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}
</div>