feat: clone course (#582)

* feat: clone course work

* feat: clone and delete a course

* feat: clone and delete a course

* feat: clean up

* feat: attending to reviews

* feat: attended to revies
This commit is contained in:
Nwosu Emmanuel
2025-10-29 09:57:29 +01:00
committed by GitHub
parent f8271d850f
commit 8efffcb7f6
27 changed files with 900 additions and 382 deletions
+1
View File
@@ -1,6 +1,7 @@
## Supabase - DB
PUBLIC_SUPABASE_ANON_KEY=
PUBLIC_SUPABASE_URL=
PRIVATE_SUPABASE_SERVICE_ROLE=
## Cloudflare - Video uploads
# Not required - https://cdn.example.com
+1
View File
@@ -11,6 +11,7 @@ const envSchema = z.object({
PORT: z.string().optional(),
PUBLIC_SUPABASE_ANON_KEY: z.string(),
PUBLIC_SUPABASE_URL: z.string(),
PRIVATE_SUPABASE_SERVICE_ROLE: z.string(),
REDIS_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
+42
View File
@@ -0,0 +1,42 @@
import { Hono } from 'hono';
import { ZCourseClone, TCourseClone } from '$src/types/course';
import { zValidator } from '@hono/zod-validator';
import { cloneCourse } from '$src/services/course/clone';
import { authMiddleware } from '$src/middlewares/auth';
import type { CloneCourseResponse, CloneCourseErrorResponse } from '$src/types/database';
import type { User } from '@supabase/supabase-js';
type Variables = {
user: User;
};
export const cloneRouter = new Hono<{ Variables: Variables }>().post(
'/',
authMiddleware,
zValidator('json', ZCourseClone),
async (c) => {
try {
const validatedData = (c.req.valid as (key: 'json') => TCourseClone)('json');
const { id, title, description, slug, organizationId } = validatedData;
// Get user from context (set by authMiddleware)
const user = c.get('user');
// Clone the course
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
const successResponse: CloneCourseResponse = {
success: true,
course: newCourse
};
return c.json(successResponse, 200);
} catch (error) {
console.error('Error cloning course:', error);
const errorResponse: CloneCourseErrorResponse = {
error: 'Failed to clone course',
details: error instanceof Error ? error.message : 'Unknown error'
};
return c.json(errorResponse, 500);
}
}
);
+6 -4
View File
@@ -1,4 +1,4 @@
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
import { Hono } from 'hono';
import { generateCertificate } from '$src/utils/certificate';
@@ -6,11 +6,12 @@ import { generateCoursePdf } from '$src/utils/course';
import { katexRouter } from '$src/routes/course/katex';
import { lessonRouter } from '$src/routes/course/lesson';
import { presignRouter } from '$src/routes/course/presign';
import { cloneRouter } from '$src/routes/course/clone';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
const validatedData = c.req.valid('json');
const validatedData = (c.req.valid as (key: 'json') => TCertificateDownload)('json');
const result = await generateCertificate(validatedData);
@@ -30,7 +31,7 @@ export const courseRouter = new Hono()
);
})
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
const validatedData = (c.req.valid as (key: 'json') => TCourseDownloadContent)('json');
const pdfBuffer = await generateCoursePdf(validatedData);
@@ -47,4 +48,5 @@ export const courseRouter = new Hono()
})
.route('/katex', katexRouter)
.route('/lesson', lessonRouter)
.route('/presign', presignRouter);
.route('/presign', presignRouter)
.route('/clone', cloneRouter);
+2 -2
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { ZLessonDownloadContent } from '$src/types/course/lesson';
import { TLessonDownloadContent, ZLessonDownloadContent } from '$src/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson';
import { zValidator } from '@hono/zod-validator';
@@ -7,7 +7,7 @@ export const lessonRouter = new Hono().post(
'/download/pdf',
zValidator('json', ZLessonDownloadContent),
async (c) => {
const validatedData = c.req.valid('json');
const validatedData = (c.req.valid as (key: 'json') => TLessonDownloadContent)('json');
const pdfBuffer = await generateLessonPdf(validatedData);
+369
View File
@@ -0,0 +1,369 @@
import { getSupabase } from '$src/utils/supabase';
import type { Course, Lesson, LessonSection, GroupMember } from '$src/types/database';
import type { PostgrestError } from '@supabase/supabase-js';
const supabase = getSupabase();
// Constants
const QUESTION_TYPE_TEXTAREA = 2; // Paragraph question type
const ROLE_TUTOR = 2; // Tutor role ID
// Reusable deep select query for lessons with all nested relations
const LESSON_DEEP_SELECT = `
*,
exercise!exercise_lesson_id_fkey(
id, title, description, due_by,
question!question_exercise_id_fkey(
*,
option!option_question_id_fkey(*),
question_type!question_question_type_id_fkey(id, label)
)
),
lesson_language!public_lesson_language_lesson_id_fkey(id, content, locale)
`;
interface FetchCourseDataResponse {
data: Course | null;
error: PostgrestError | null;
}
interface CloneGroupResponse {
newCourse: Course;
}
interface CloneSectionsResponse {
newSections: LessonSection[];
sectionIdMap?: Map<string, string>;
}
interface CloneLessonsResponse {
newLessons: Lesson[] | null;
}
interface AddGroupMemberParams {
profile_id: string;
email: string;
group_id: string;
role_id: number;
}
// fetches course data with backward compatibility for courses that is either V1 or V2
async function fetchCourseData(courseId: string): Promise<FetchCourseDataResponse> {
const { data, error } = await supabase
.from('course')
.select(
`
*,
lesson_section(
id, title, order, created_at,
lessons:lesson(${LESSON_DEEP_SELECT})
),
lessons:lesson(${LESSON_DEEP_SELECT})
`
)
.eq('id', courseId)
.single();
return { data, error };
}
async function addGroupMember(params: AddGroupMemberParams): Promise<{
data: GroupMember[] | null;
error: PostgrestError | null;
}> {
const { data, error } = await supabase.from('groupmember').insert(params).select();
return { data, error };
}
async function cloneGroupAndBasicCourse(
course: Course,
newTitle: string,
userId: string,
newDescription?: string,
newSlug?: string,
organizationId?: string
): Promise<CloneGroupResponse> {
const { description } = course;
const finalDescription = newDescription || description;
const finalSlug = newSlug || null; // Use provided slug or null
// 1. Create group
const { data: newGroup, error: groupError } = await supabase
.from('group')
.insert({
name: newTitle,
description: finalDescription,
organization_id: organizationId
})
.select();
if (groupError) {
console.error('Group creation error:', groupError);
throw new Error(`Group creation failed: ${groupError.message}`);
}
if (!newGroup || newGroup.length === 0) {
throw new Error('Group was not created successfully');
}
const { id: group_id } = newGroup[0];
// Remove nested relations and metadata that shouldn't be inserted
const { lessons, lesson_section, id, updated_at, created_at, ...courseData } = course as any;
// 2. Create course with group_id
const { data: newCourse, error: courseError } = await supabase
.from('course')
.insert({
...courseData,
title: newTitle,
description: finalDescription,
slug: finalSlug,
group_id
})
.select();
if (courseError) {
console.error('Course creation error:', courseError);
throw new Error(`Course creation failed: ${courseError.message}`);
}
if (!newCourse || newCourse.length === 0) {
throw new Error('Course was not created successfully');
}
// 3. Add group member (the user who is cloning)
await addGroupMember({
profile_id: userId,
email: '', // Email will be fetched from profile
group_id,
role_id: ROLE_TUTOR
});
return {
newCourse: newCourse[0]
};
}
async function cloneLessonSections(
sections: LessonSection[],
courseId: string
): Promise<CloneSectionsResponse> {
if (!sections || sections.length === 0) {
return { newSections: [], sectionIdMap: undefined };
}
const clonedSections = sections
.sort((a, b) => new Date(a.created_at || '').getTime() - new Date(b.created_at || '').getTime())
.map((section) => ({
title: section.title,
order: section.order,
course_id: courseId
}));
const { data: newSections } = await supabase
.from('lesson_section')
.insert(clonedSections)
.select();
// Map old section IDs to new section IDs
const sectionIdMap = new Map<string, string>();
sections.forEach((oldSection, index) => {
if (newSections && newSections[index]) {
sectionIdMap.set(oldSection.id, newSections[index].id);
}
});
return { newSections: newSections || [], sectionIdMap };
}
async function cloneLessons(
lessons: Lesson[],
courseId: string,
sectionIdMap?: Map<string, string>
): Promise<CloneLessonsResponse> {
const clonedLessons = lessons
.sort((a, b) => new Date(a.created_at || '').getTime() - new Date(b.created_at || '').getTime())
.map((lesson, index) => ({
call_url: lesson.call_url,
course_id: courseId,
section_id: lesson.section_id && sectionIdMap ? sectionIdMap.get(lesson.section_id) : null,
is_unlocked: lesson.is_unlocked,
lesson_at: new Date().toISOString(),
note: lesson.note,
order: lesson.order || index + 1,
public: lesson.public,
slide_url: lesson.slide_url,
title: lesson.title,
videos: lesson.videos
}));
const { data } = await supabase.from('lesson').insert(clonedLessons).select();
const newLessons = data?.map((dataItem, dataIndex) => {
return {
...dataItem,
exercise: lessons[dataIndex].exercise,
lesson_language: lessons[dataIndex].lesson_language
};
});
return { newLessons };
}
async function cloneLessonLanguages(lessons: Lesson[]): Promise<boolean> {
for (const lesson of lessons) {
const { id: lessonId, lesson_language = [] } = lesson;
if (!lesson_language || lesson_language.length === 0) continue;
const clonedLanguages = lesson_language.map((lang: any) => ({
lesson_id: lessonId,
content: lang.content,
locale: lang.locale
}));
const { error } = await supabase.from('lesson_language').insert(clonedLanguages).select();
if (error) {
console.error('Error cloning lesson languages:', error);
throw error;
}
}
return true;
}
async function cloneExercises(lessons: Lesson[]): Promise<boolean> {
// Clone exercises, questions, and options
for (const lesson of lessons) {
const { id: lessonId, exercise = [] } = lesson;
// Exercises
for (const exerciseItem of exercise) {
const { title, description, questions = [] } = exerciseItem;
// Insert exercise
const { data } = await supabase
.from('exercise')
.insert({
description,
due_by: new Date().toISOString(),
lesson_id: lessonId,
title
})
.select();
const newExercise = Array.isArray(data) ? data[0] : data;
if (!newExercise) continue;
// Questions
for (const question of questions) {
const { options = [] } = question;
// Insert question
const { data: questionData } = await supabase
.from('question')
.insert({
name: question.name,
title: question.title,
points: question.points,
order: question.order,
question_type_id: question.question_type_id,
exercise_id: newExercise.id
})
.select();
const newQuestion = Array.isArray(questionData) ? questionData[0] : questionData;
if (!newQuestion) continue;
// Options: Don't map options for 'Paragraph' questions
if (QUESTION_TYPE_TEXTAREA !== newQuestion.question_type_id && options.length > 0) {
const newOptions = options.map((option: any) => ({
value: option.value,
label: option.label,
is_correct: option.is_correct,
question_id: newQuestion.id
}));
// Insert options in batch
await supabase.from('option').insert(newOptions).select();
}
}
}
}
return true;
}
export async function cloneCourse(
courseId: string,
newTitle: string,
userId: string,
newDescription?: string,
newSlug?: string,
organizationId?: string
): Promise<Course> {
const { data: course, error } = await fetchCourseData(courseId);
if (error) {
console.error('Error when cloning', error);
throw error;
}
if (!course) {
throw new Error('Course not found');
}
// 1. Clone course and group
const { newCourse } = await cloneGroupAndBasicCourse(
course,
newTitle,
userId,
newDescription,
newSlug,
organizationId
);
let sectionIdMap: Map<string, string> | undefined = undefined;
let allLessons: Lesson[] = [];
// 2. Clone sections if this is a V2 course with sections
// @ts-ignore - lesson_section comes from the query
if (course.lesson_section && course.lesson_section.length > 0) {
// @ts-ignore
const { sectionIdMap: map } = await cloneLessonSections(course.lesson_section, newCourse.id);
sectionIdMap = map;
// Collect all lessons from sections
// @ts-ignore
course.lesson_section.forEach((section: any) => {
if (section.lessons && section.lessons.length > 0) {
allLessons = [...allLessons, ...section.lessons];
}
});
}
// 3. Also add lessons that are not in sections (V1 courses or orphaned lessons)
// @ts-ignore - lessons comes from the query
if (course.lessons && course.lessons.length > 0) {
// @ts-ignore
const orphanedLessons = course.lessons.filter(
(lesson: Lesson) => !lesson.section_id || !sectionIdMap
);
allLessons = [...allLessons, ...orphanedLessons];
}
// 4. Clone lessons
const { newLessons } = await cloneLessons(allLessons, newCourse.id, sectionIdMap);
// 5. Clone lesson languages
await cloneLessonLanguages(newLessons || []);
// 6. Clone exercises, questions, and options
await cloneExercises(newLessons || []);
return newCourse;
}
+10
View File
@@ -48,3 +48,13 @@ export const ZCourseDownloadPresignedUrl = z.object({
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
export const ZCourseClone = z.object({
id: z.string().min(1, 'Course ID is required'),
title: z.string().min(1, 'Course title is required'),
description: z.string().optional(),
slug: z.string().min(1, 'Course slug is required'),
organizationId: z.string().min(1, 'Organization ID is required')
});
export type TCourseClone = z.infer<typeof ZCourseClone>;
+145
View File
@@ -0,0 +1,145 @@
// Database types for course cloning
export interface Course {
id: string;
title: string;
description: string;
type: string;
version: 'V1' | 'V2';
overview?: string;
created_at: string;
updated_at: string;
group_id?: string;
is_template?: boolean;
organization_id?: string;
logo?: string;
slug?: string;
metadata?: Record<string, unknown>;
cost?: number;
currency?: string;
is_certificate_downloadable?: boolean;
certificate_theme?: string;
status?: string;
is_published?: boolean;
lesson_section?: LessonSection[];
lessons?: Lesson[];
}
export interface LessonSection {
id: string;
title: string;
order: number;
course_id: string;
created_at: string;
updated_at?: string;
lessons?: Lesson[];
}
export interface Lesson {
id: string;
title: string;
note?: string;
videos?: unknown[];
slide_url?: string;
course_id: string;
section_id?: string;
created_at: string;
updated_at?: string;
public?: boolean;
lesson_at?: string;
teacher_id?: string;
is_unlocked?: boolean;
call_url?: string;
order?: number;
exercise?: Exercise[];
lesson_language?: LessonLanguage[];
}
export interface LessonLanguage {
id: number;
content: string;
lesson_id: string;
locale: 'en' | 'hi' | 'fr' | 'pt' | 'de' | 'vi' | 'ru' | 'es' | 'pl' | 'da';
}
export interface Exercise {
id: string;
title: string;
description?: string;
lesson_id?: string;
created_at?: string;
updated_at?: string;
due_by?: string;
questions?: Question[];
}
export interface Question {
id: number;
question_type_id: number;
title: string;
name?: string;
points?: number;
order?: number;
created_at?: string;
updated_at?: string;
exercise_id: string;
question_type?: QuestionType;
options?: Option[];
}
export interface QuestionType {
id: number;
label: string;
typename?: string;
}
export interface Option {
id: number;
label: string;
is_correct: boolean;
question_id: number;
value?: string;
created_at?: string;
updated_at?: string;
}
export interface Group {
id: string;
name: string;
description?: string;
created_at?: string;
updated_at?: string;
organization_id?: string;
}
export interface GroupMember {
id: string;
group_id: string;
role_id: number;
profile_id?: string;
email?: string;
created_at?: string;
assigned_student_id?: string;
}
export interface Profile {
id: string;
fullname: string;
username: string;
avatar_url?: string;
email?: string;
created_at?: string;
updated_at?: string;
}
// Response types
export interface CloneCourseResponse {
success: boolean;
course: Course;
}
export interface CloneCourseErrorResponse {
error: string;
details?: string;
}
+5
View File
@@ -0,0 +1,5 @@
// Re-export all types
export * from './course';
export * from './database';
export * from './mail';
+4 -9
View File
@@ -2,7 +2,7 @@ import { SupabaseClient, createClient } from '@supabase/supabase-js';
import { env } from '$src/config/env';
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE } = env;
export let supabase: SupabaseClient | null = null;
@@ -16,15 +16,9 @@ interface SupabaseConfig {
export function getSupabase(config?: SupabaseConfig): SupabaseClient {
if (supabase) return supabase;
const finalConfig = config ?? {
supabaseConfig: {
url: PUBLIC_SUPABASE_URL,
anonKey: PUBLIC_SUPABASE_ANON_KEY
}
};
try {
supabase = createClient(finalConfig.supabaseConfig.url, finalConfig.supabaseConfig.anonKey, {
// Use service role key to bypass RLS policies
supabase = createClient(PUBLIC_SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE, {
auth: { persistSession: false }
});
} catch (error) {
@@ -34,3 +28,4 @@ export function getSupabase(config?: SupabaseConfig): SupabaseClient {
return supabase;
}
@@ -16,7 +16,7 @@
import GrowthIcon from 'carbon-icons-svelte/lib/Growth.svelte';
import RadioButtonChecked from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
import UserProfileIcon from 'carbon-icons-svelte/lib/UserProfile.svelte';
import { copyCourseModal, deleteCourseModal } from '$lib/components/Courses/store';
export let bannerImage: string | undefined;
export let id = '';
export let slug = '';
@@ -43,8 +43,10 @@
$: formatter = getCurrencyFormatter(currency);
function handleCloneCourse() {
// TODO: Clone course functionality
alert('WIP: Clone course');
$copyCourseModal.open = true;
$copyCourseModal.id = id;
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
function handleShareCourse() {
@@ -56,7 +58,9 @@
}
function handleDeleteCourse() {
goto(`/courses/${id}/settings#delete`);
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
const COURSE_TAG: Record<
@@ -122,7 +126,10 @@
>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.clone')}
on:click={handleCloneCourse}
on:click={(e) => {
e.stopPropagation();
handleCloneCourse();
}}
/>
<OverflowMenuItem
text={$t('courses.course_card.context_menu.share')}
@@ -135,7 +142,10 @@
<OverflowMenuItem
danger
text={$t('courses.course_card.context_menu.delete')}
on:click={handleDeleteCourse}
on:click={(e) => {
e.stopPropagation();
handleDeleteCourse();
}}
/>
</OverflowMenu>
{/if}
@@ -1,23 +1,56 @@
<script>
<script lang="ts">
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { copyCourseModalInitialState, copyCourseModal } from '$lib/components/Courses/store';
import { cloneCourse } from '$lib/utils/services/courses/clone';
import { classroomio } from '$lib/utils/services/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
interface CloneCourseResponse {
success: boolean;
course: { id: string };
error?: string;
}
// clone, show spinner and redirect to new course
async function createCourse() {
// Prevent double submission (Option 1: Early Return Guard)
if ($copyCourseModal.isSaving) return;
console.log('Create course');
$copyCourseModal.isSaving = true;
try {
const newCourse = await cloneCourse($copyCourseModal.id, $copyCourseModal.title);
// Generate unique slug from the title
const slug = generateSlug($copyCourseModal.title);
goto(`/courses/${newCourse.id}`);
const response = await classroomio.course.clone.$post({
json: {
id: $copyCourseModal.id,
title: $copyCourseModal.title,
description: $copyCourseModal.description,
slug,
organizationId: $currentOrg.id
}
});
const result = (await response.json()) as CloneCourseResponse;
if (!response.ok) {
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
}
// Option 3: Close modal and reset state before navigation
copyCourseModal.set(copyCourseModalInitialState);
// Navigate to the new course
goto(`/courses/${result.course.id}`);
$copyCourseModal.open = false;
} catch (error) {
console.error(error);
@@ -36,25 +69,34 @@
onClose={() => ($copyCourseModal.open = false)}
bind:open={$copyCourseModal.open}
width="w-96"
modalHeading="Create a Course"
modalHeading={$t('courses.copy_course.title')}
>
<form on:submit|preventDefault={createCourse}>
<TextField
label="Course name"
label={$t('courses.copy_course.course_name_label')}
bind:value={$copyCourseModal.title}
autoFocus={true}
placeholder="Your course name"
placeholder={$t('courses.copy_course.course_name_placeholder')}
className="mb-4"
isRequired={true}
autoComplete={false}
/>
<div class="mt-5 flex items-center flex-row-reverse">
<TextArea
label={$t('courses.copy_course.course_description_label')}
bind:value={$copyCourseModal.description}
placeholder={$t('courses.copy_course.course_description_placeholder')}
className="mb-4"
rows={4}
/>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label="Create"
label={$t('courses.copy_course.create_button')}
type="submit"
isLoading={$copyCourseModal.isSaving}
isDisabled={$copyCourseModal.isSaving}
/>
</div>
</form>
@@ -15,7 +15,12 @@
StructuredListBody
} from 'carbon-components-svelte';
import { t } from '$lib/utils/functions/translations';
import CopyCourseModal from './components/CopyCourseModal/index.svelte';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import { deleteCourse } from '$lib/utils/services/courses';
import { snackbar } from '$lib/components/Snackbar/store';
import { deleteCourseModal, deleteCourseModalInitialState, courses as coursesStore } from './store';
export let courses: Course[] = [];
export let emptyTitle = $t('courses.course_card.empty_title');
export let emptyDescription = $t('courses.course_card.empty_description');
@@ -28,9 +33,39 @@
return Math.round((progressRate / totalLessons) * 100);
}
async function handleDeleteCourse() {
if (!$deleteCourseModal.id) return;
$deleteCourseModal.isDeleting = true;
try {
await deleteCourse($deleteCourseModal.id);
// Remove the course from the courses store
$coursesStore = $coursesStore.filter(course => course.id !== $deleteCourseModal.id);
// Show success message
snackbar.success('snackbar.course_deleted');
// Close modal and reset state
deleteCourseModal.set(deleteCourseModalInitialState);
} catch (error) {
console.error('Error deleting course:', error);
snackbar.error('snackbar.course_settings.error.went_wrong');
// Stop deleting state on error
$deleteCourseModal.isDeleting = false;
}
}
</script>
<!-- <CopyCourseModal /> -->
<CopyCourseModal />
<DeleteModal
onDelete={handleDeleteCourse}
bind:open={$deleteCourseModal.open}
isLoading={$deleteCourseModal.isDeleting}
/>
<div class="mx-auto my-4 w-full">
{#if $courseMetaDeta.isLoading}
@@ -40,8 +40,18 @@ export const copyCourseModalInitialState = {
courseIdToCopy: null,
id: '',
title: '',
description: '',
isSaving: false,
error: null
};
export const copyCourseModal = writable({ ...copyCourseModalInitialState });
export const deleteCourseModalInitialState = {
open: false,
id: '',
title: '',
isDeleting: false
};
export const deleteCourseModal = writable({ ...deleteCourseModalInitialState });
@@ -6,15 +6,11 @@
export let open = false;
export let onDelete = () => {};
function handleDelete() {
onDelete();
open = false;
}
export let isLoading = false;
</script>
<Modal
onClose={() => (open = false)}
onClose={() => !isLoading && (open = false)}
bind:open
width="w-96"
containerClass="p-6"
@@ -32,12 +28,14 @@
variant={VARIANTS.OUTLINED}
label={$t('delete_modal.no')}
onClick={() => (open = false)}
{isLoading}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED_DANGER}
label={$t('delete_modal.yes')}
onClick={handleDelete}
onClick={onDelete}
{isLoading}
/>
</div>
</div>
@@ -1,256 +0,0 @@
import type { Course, Lesson, Profile } from '$lib/utils/types';
import type { PostgrestError } from '@supabase/supabase-js';
// @ts-ignore
import { QUESTION_TYPE } from '$lib/components/Question/constants';
// @ts-ignore
import { ROLE } from '$lib/utils/constants/roles';
// @ts-ignore
import { addGroupMember } from './index';
import { get } from 'svelte/store';
// @ts-ignore
import { profile } from '$lib/utils/store/user';
import { supabase } from '$lib/utils/functions/supabase';
type GetCourseResponse = {
data: Course;
error: PostgrestError | null;
};
async function fetchCourseData(courseId: string): Promise<GetCourseResponse> {
const { data, error } = await supabase
.from('course')
.select(
`
*,
lessons:lesson(
*,
exercise:id(
id, title, description, due_by,
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
)
)
`
)
.match({ id: courseId })
.single();
return { data, error };
}
async function cloneGroupAndBasicCourse(
course: Course,
newTitle: string
): Promise<{ newCourse: Course }> {
const { description } = course;
const { data: newGroup } = await supabase
.from('group')
.insert({ name: newTitle, description })
.select();
console.log(`newGroup`, newGroup);
if (!Array.isArray(newGroup)) {
throw new Error('Group wasnt created successfully');
}
const { id: group_id } = newGroup[0];
// 2. Create course with group_id
const { data: newCourse } = await supabase
.from('course')
.insert({
...course,
title: newTitle,
description,
group_id,
lessons: undefined,
id: undefined,
updated_at: undefined,
created_at: undefined
})
.select();
console.log(`newCourse`, newCourse);
// 3. Add group members
const profileStoreValue: Profile = get(profile);
// Get profile of author and add as member
await addGroupMember({
profile_id: profileStoreValue.id,
email: profileStoreValue.email,
group_id,
role_id: ROLE.TUTOR
});
console.log('profileStoreValue', profileStoreValue);
return {
newCourse: newCourse?.[0]
};
}
async function cloneLessons(lessons: Lesson[], courseId: Course['id']) {
const clonedLessons = lessons
.sort((a, b) => new Date(a.created_at || '').getTime() - new Date(b.created_at || '').getTime())
.map((lesson, index) => ({
call_url: lesson.call_url,
course_id: courseId,
is_unlocked: lesson.is_unlocked,
lesson_at: new Date(),
note: lesson.note,
order: lesson.order || index + 1,
public: lesson.public,
slide_url: lesson.slide_url,
title: lesson.title,
videos: lesson.videos
}));
const { data } = await supabase.from('lesson').insert(clonedLessons).select();
const newLessons = data?.map((dataItem, dataIndex) => {
return {
...dataItem,
// @ts-ignore
exercise: lessons[dataIndex].exercise
};
});
console.log('newLessons', newLessons);
return { newLessons };
}
async function cloneExercises(lessons: Lesson[]) {
console.log('lessons', lessons);
// Lessons
for (const lesson of lessons) {
// @ts-ignore
const { id: lessonId, exercise = [] } = lesson;
// Exercises
for (const exerciseItem of exercise) {
const { title, description, questions = [] } = exerciseItem;
// Insert
const { data } = await supabase
.from('exercise')
.insert({
description,
due_by: new Date(),
lesson_id: lessonId,
title
})
.select();
const newExercise = Array.isArray(data) ? data[0] : data;
console.log('newExercise', newExercise);
// Questions
for (const question of questions) {
const { options = [] } = question;
// Insert
const { data } = await supabase
.from('question')
.insert({
name: question.name,
title: question.title,
points: question.points,
order: question.order,
question_type_id: question.question_type_id,
exercise_id: newExercise.id
})
.select();
const newQuestion = Array.isArray(data) ? data[0] : data;
console.log('newQuestion', newQuestion);
// Options: Don't map options for 'Paragraph' questions
if (QUESTION_TYPE.TEXTAREA !== newQuestion.question_type_id) {
// @ts-ignore
const newOptions = options.map((option) => ({
value: option.value,
label: option.label,
is_correct: option.is_correct,
question_id: newQuestion.id
}));
// Insert[Batch]
await supabase.from('option').insert(newOptions).select();
}
}
}
}
return true;
}
export async function cloneCourse(courseId: string, newTitle: string): Promise<Course> {
const { data: course, error } = await fetchCourseData(courseId);
console.log(`course`, course);
if (error) {
console.error('Error when cloning', error);
throw error;
}
/**
* 1. Copy course table
* 2. Copy lessons table (NOTE: Copy sections table here)
* 3. Copy exercises table
* 4. Copy questions table
* 5. Copy options table
*/
// tables: group, groupmember, course(group_id)
const { newCourse } = await cloneGroupAndBasicCourse(course, newTitle);
console.log('returned -> newCourse', newCourse);
// @ts-ignore
const { newLessons } = await cloneLessons(course.lessons, newCourse.id);
console.log('returned -> newLessons', newLessons);
const cloneExercisesRes = await cloneExercises(newLessons || []);
console.log('returned -> cloneExercisesRes', cloneExercisesRes);
return newCourse;
}
/*
1. Click on 3 dots to clone course
2. Show a modal with a new name "Copy of previous course name"
-> Note the following information will be copied
3. Things to copy
Lessons
Exercises - questions, options, everything basically.
Technical approach
- Fetch all the data to the client
- Manually call functions to create
- call a psql function with newname, course_id
- copy all in course table
- copy all in group table by course_id
- add only one group member as admin
- get all lessons in course
- copy each one with unique ids
- get all exercises by course id
- copy exercise
- get questions
- copy questions
- get options
- copy options
*/
@@ -1090,6 +1090,17 @@
"date_created": "Dato oprettet",
"published": "Offentliggjort",
"lessons": "Lektioner"
},
"copy_course": {
"title": "Opret et kursus",
"course_name_label": "Kursusnavn",
"course_name_placeholder": "Dit kursusnavn",
"course_description_label": "Kursusbeskrivelse",
"course_description_placeholder": "Indtast en beskrivelse til dit kursus",
"create_button": "Skabe",
"error": {
"failed_to_clone": "Kunne ikke klone kursus"
}
}
},
"ai": {
@@ -937,6 +937,17 @@
"date_created": "Datum erstellt",
"published": "Veröffentlicht",
"lessons": "Unterricht"
},
"copy_course": {
"title": "Erstellen Sie einen Kurs",
"course_name_label": "Kursname",
"course_name_placeholder": "Ihr Kursname",
"course_description_label": "Kursbeschreibung",
"course_description_placeholder": "Geben Sie eine Beschreibung für Ihren Kurs ein",
"create_button": "Erstellen",
"error": {
"failed_to_clone": "Der Kurs konnte nicht geklont werden"
}
}
},
"ai": {
@@ -540,6 +540,17 @@
"button": "Finish",
"back": "Back",
"next": "Next"
},
"copy_course": {
"title": "Create a Course",
"course_name_label": "Course name",
"course_name_placeholder": "Your course name",
"course_description_label": "Course description",
"course_description_placeholder": "Enter a description for your course",
"create_button": "Create",
"error": {
"failed_to_clone": "Failed to clone course"
}
}
},
@@ -937,6 +937,17 @@
"date_created": "fecha de creacion",
"published": "Publicado",
"lessons": "Lecciones"
},
"copy_course": {
"title": "Crear un curso",
"course_name_label": "Nombre del curso",
"course_name_placeholder": "Nombre de tu curso",
"course_description_label": "Descripción del curso",
"course_description_placeholder": "Introduce una descripción para tu curso.",
"create_button": "Crear",
"error": {
"failed_to_clone": "No se pudo clonar el curso"
}
}
},
"ai": {
@@ -937,6 +937,17 @@
"date_created": "date créée",
"published": "Publié",
"lessons": "Cours"
},
"copy_course": {
"title": "Créer un cours",
"course_name_label": "Nom du cours",
"course_name_placeholder": "Le nom de votre cours",
"course_description_label": "Descriptif du cours",
"course_description_placeholder": "Entrez une description pour votre cours",
"create_button": "Créer",
"error": {
"failed_to_clone": "Échec du clonage du cours"
}
}
},
"ai": {
@@ -937,6 +937,17 @@
"date_created": "बनाया गया दिनांक",
"published": "प्रकाशित",
"lessons": "पाठ"
},
"copy_course": {
"title": "एक कोर्स बनाएं",
"course_name_label": "कोर्स का नाम",
"course_name_placeholder": "आपके पाठ्यक्रम का नाम",
"course_description_label": "पाठ्यक्रम विवरण",
"course_description_placeholder": "अपने पाठ्यक्रम के लिए विवरण दर्ज करें",
"create_button": "बनाएं",
"error": {
"failed_to_clone": "पाठ्यक्रम क्लोन करने में विफल"
}
}
},
"ai": {
@@ -1086,6 +1086,17 @@
"date_created": "Utworzona data",
"published": "Opublikowany",
"lessons": "Lekcje"
},
"copy_course": {
"title": "Utwórz kurs",
"course_name_label": "Nazwa kursu",
"course_name_placeholder": "Nazwa Twojego kursu",
"course_description_label": "Opis kursu",
"course_description_placeholder": "Wprowadź opis swojego kursu",
"create_button": "Tworzyć",
"error": {
"failed_to_clone": "Nie udało się sklonować kursu"
}
}
},
"ai": {
@@ -937,6 +937,17 @@
"date_created": "Data Criada",
"published": "Publicados",
"lessons": "Lições"
},
"copy_course": {
"title": "Crie um curso",
"course_name_label": "Nome do curso",
"course_name_placeholder": "Nome do seu curso",
"course_description_label": "Descrição do curso",
"course_description_placeholder": "Insira uma descrição para o seu curso",
"create_button": "Criar",
"error": {
"failed_to_clone": "Falha ao clonar curso"
}
}
},
"ai": {
@@ -937,6 +937,17 @@
"date_created": "Дата создания",
"published": "Опубликовано",
"lessons": "Уроки"
},
"copy_course": {
"title": "Создать курс",
"course_name_label": "Название курса",
"course_name_placeholder": "Название вашего курса",
"course_description_label": "Описание курса",
"course_description_placeholder": "Введите описание вашего курса",
"create_button": "Создавать",
"error": {
"failed_to_clone": "Не удалось клонировать курс."
}
}
},
"ai": {
@@ -937,6 +937,17 @@
"date_created": "Ngày tạo",
"published": "Được phát hành",
"lessons": "Những bài học"
},
"copy_course": {
"title": "Tạo một khóa học",
"course_name_label": "Tên khóa học",
"course_name_placeholder": "Tên khóa học của bạn",
"course_description_label": "Mô tả khóa học",
"course_description_placeholder": "Nhập mô tả cho khóa học của bạn",
"create_button": "Tạo nên",
"error": {
"failed_to_clone": "Không thể sao chép khóa học"
}
}
},
"ai": {