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 <rotimi-best@users.noreply.github.com> * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { cohortApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
let isLoadingStudents = $state(false);
|
||||
let activeTab = $state<'tutors' | 'students'>('students');
|
||||
|
||||
const addPeopleParam = $derived(new URLSearchParams(page.url.search).get('add'));
|
||||
const isOpen = $derived(addPeopleParam === 'true');
|
||||
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, cohortApi.members));
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutorIds = new Set(
|
||||
cohortApi.members
|
||||
.filter((member) => Number(member.roleId) === ROLE.TUTOR || Number(member.roleId) === ROLE.ADMIN)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
);
|
||||
|
||||
return team
|
||||
.filter((teamMember) => teamMember.verified)
|
||||
.filter((teamMember) => !existingTutorIds.has(teamMember.profileId))
|
||||
.map((teamMember) => ({
|
||||
id: teamMember.id,
|
||||
text: teamMember.fullname,
|
||||
profileId: teamMember.profileId,
|
||||
email: teamMember.email
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableStudents(students: OrgStudent[], cohortMembers: typeof cohortApi.members) {
|
||||
const existingStudentIds = new Set(
|
||||
cohortMembers
|
||||
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
);
|
||||
|
||||
return students.filter((student) => {
|
||||
if (!student.profileId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !existingStudentIds.has(student.profileId);
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
}
|
||||
|
||||
function setTutors(orgId: string | undefined) {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
await orgApi.getOrgTeam();
|
||||
if (orgApi.error) {
|
||||
console.error('Error fetching teams', orgApi.error);
|
||||
return;
|
||||
}
|
||||
|
||||
tutors = getTutors(orgApi.teamMembers);
|
||||
});
|
||||
}
|
||||
|
||||
function loadStudents(orgId: string | undefined) {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStudentSearch(value: string) {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(
|
||||
orgId,
|
||||
{
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
search: value.trim() || undefined
|
||||
},
|
||||
{ abortPrevious: true }
|
||||
);
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addTutors() {
|
||||
if (!selectedTutors.length) {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
cohortApi.success = false;
|
||||
|
||||
await cohortApi.addMembers(cohortId, {
|
||||
members: selectedTutors.map((tutor) => ({
|
||||
profileId: tutor.profileId,
|
||||
roleId: ROLE.TUTOR,
|
||||
email: tutor.email,
|
||||
name: tutor.text
|
||||
}))
|
||||
});
|
||||
|
||||
if (cohortApi.success) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
||||
await cohortApi.assignExistingStudentsToCohort(cohortId, { profileIds, sendEmail });
|
||||
}
|
||||
|
||||
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
||||
await cohortApi.inviteStudentsToCohort(cohortId, { recipientCsv, sendEmail });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setTutors($currentOrg.id);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen || !cohortId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
activeTab = 'students';
|
||||
selectedIds = [];
|
||||
void loadStudents($currentOrg.id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeModal();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('cohorts.people.invite_modal_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<UnderlineTabs.Root bind:value={activeTab}>
|
||||
<UnderlineTabs.List class="flex flex-wrap">
|
||||
<UnderlineTabs.Trigger value="tutors">
|
||||
{$t(`${INVITE_MODAL}.invite`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="students">
|
||||
{$t(`${INVITE_MODAL}.invite_students`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="space-y-3">
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
isLoading={orgApi.isLoading}
|
||||
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="secondary" type="button" onclick={addTutors} loading={cohortApi.isLoading}>
|
||||
{$t(`${INVITE_MODAL}.finish`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="space-y-6">
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user