diff --git a/apps/api/.env.example b/apps/api/.env.example index ca67045a..4158bf20 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 84d8864c..e2fc8005 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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(), diff --git a/apps/api/src/routes/course/clone.ts b/apps/api/src/routes/course/clone.ts new file mode 100644 index 00000000..e43f0be3 --- /dev/null +++ b/apps/api/src/routes/course/clone.ts @@ -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); + } + } +); diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts index 5c0dc830..869e284d 100644 --- a/apps/api/src/routes/course/course.ts +++ b/apps/api/src/routes/course/course.ts @@ -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); diff --git a/apps/api/src/routes/course/lesson.ts b/apps/api/src/routes/course/lesson.ts index e380a627..0307bccd 100644 --- a/apps/api/src/routes/course/lesson.ts +++ b/apps/api/src/routes/course/lesson.ts @@ -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); diff --git a/apps/api/src/services/course/clone.ts b/apps/api/src/services/course/clone.ts new file mode 100644 index 00000000..d4b7e78d --- /dev/null +++ b/apps/api/src/services/course/clone.ts @@ -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; +} + +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 { + 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 { + 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 { + 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(); + 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 +): Promise { + 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 { + 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 { + // 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 { + 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 | 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; +} diff --git a/apps/api/src/types/course/index.ts b/apps/api/src/types/course/index.ts index f26086a7..574bffae 100644 --- a/apps/api/src/types/course/index.ts +++ b/apps/api/src/types/course/index.ts @@ -48,3 +48,13 @@ export const ZCourseDownloadPresignedUrl = z.object({ }); export type TCourseDownloadPresignedUrl = z.infer; + +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; diff --git a/apps/api/src/types/database.ts b/apps/api/src/types/database.ts new file mode 100644 index 00000000..67ecd87e --- /dev/null +++ b/apps/api/src/types/database.ts @@ -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; + 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; +} + diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts new file mode 100644 index 00000000..89bd1f87 --- /dev/null +++ b/apps/api/src/types/index.ts @@ -0,0 +1,5 @@ +// Re-export all types +export * from './course'; +export * from './database'; +export * from './mail'; + diff --git a/apps/api/src/utils/supabase.ts b/apps/api/src/utils/supabase.ts index 1789b49c..c8bc26e6 100644 --- a/apps/api/src/utils/supabase.ts +++ b/apps/api/src/utils/supabase.ts @@ -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; } + diff --git a/apps/dashboard/src/lib/components/Courses/components/Card/index.svelte b/apps/dashboard/src/lib/components/Courses/components/Card/index.svelte index 6753c1de..2d41511a 100644 --- a/apps/dashboard/src/lib/components/Courses/components/Card/index.svelte +++ b/apps/dashboard/src/lib/components/Courses/components/Card/index.svelte @@ -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 @@ > { + e.stopPropagation(); + handleCloneCourse(); + }} /> { + e.stopPropagation(); + handleDeleteCourse(); + }} /> {/if} diff --git a/apps/dashboard/src/lib/components/Courses/components/CopyCourseModal/index.svelte b/apps/dashboard/src/lib/components/Courses/components/CopyCourseModal/index.svelte index 3634f9a0..314d7505 100644 --- a/apps/dashboard/src/lib/components/Courses/components/CopyCourseModal/index.svelte +++ b/apps/dashboard/src/lib/components/Courses/components/CopyCourseModal/index.svelte @@ -1,23 +1,56 @@ - - + +
{#if $courseMetaDeta.isLoading} diff --git a/apps/dashboard/src/lib/components/Courses/store.ts b/apps/dashboard/src/lib/components/Courses/store.ts index 599812e0..1a3e3bb0 100644 --- a/apps/dashboard/src/lib/components/Courses/store.ts +++ b/apps/dashboard/src/lib/components/Courses/store.ts @@ -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 }); diff --git a/apps/dashboard/src/lib/components/Modal/DeleteModal.svelte b/apps/dashboard/src/lib/components/Modal/DeleteModal.svelte index 564c4611..f0710e1e 100644 --- a/apps/dashboard/src/lib/components/Modal/DeleteModal.svelte +++ b/apps/dashboard/src/lib/components/Modal/DeleteModal.svelte @@ -6,15 +6,11 @@ export let open = false; export let onDelete = () => {}; - - function handleDelete() { - onDelete(); - open = false; - } + export let isLoading = false; (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} />
diff --git a/apps/dashboard/src/lib/utils/services/courses/clone.ts b/apps/dashboard/src/lib/utils/services/courses/clone.ts deleted file mode 100644 index 417a9a5f..00000000 --- a/apps/dashboard/src/lib/utils/services/courses/clone.ts +++ /dev/null @@ -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 { - 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 { - 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 -*/ diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 23907669..aecf4a7f 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index b50ffde0..4a5b0743 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 642c7c48..bf1895c5 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -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" + } } }, diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index d108c702..17b3462f 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index b522cc9a..fe213b4d 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 2d9bee51..67a31cf2 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 2aa54f76..c933dac2 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 7dc5cfa4..d641edb0 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 80e5b0d6..298b0027 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -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": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index d00b0c06..c7087d0d 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b52cf34..964583a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,19 +317,19 @@ importers: version: 2.2.0 '@polar-sh/sveltekit': specifier: ^0.2.1 - version: 0.2.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))(zod@3.25.64) + version: 0.2.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))(zod@3.25.64) '@supabase/supabase-js': specifier: ^2.31.0 version: 2.50.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) '@sveltejs/adapter-auto': specifier: ^2.1.0 - version: 2.1.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2))) + version: 2.1.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2))) '@sveltejs/adapter-node': specifier: ^1.3.1 - version: 1.3.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2))) + version: 1.3.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2))) '@sveltejs/adapter-vercel': specifier: ^3.1.0 - version: 3.1.0(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))(encoding@0.1.13) + version: 3.1.0(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))(encoding@0.1.13) '@sveltekit-i18n/base': specifier: ^1.3.7 version: 1.3.7(svelte@4.2.20) @@ -338,7 +338,7 @@ importers: version: 1.0.8 '@tailwindcss/forms': specifier: ^0.5.4 - version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3))) + version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3))) '@types/pluralize': specifier: 0.0.30 version: 0.0.30 @@ -471,10 +471,10 @@ importers: version: 7.27.6 '@sveltejs/kit': specifier: ^1.29.0 - version: 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + version: 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) '@tailwindcss/typography': specifier: ^0.5.9 - version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3))) + version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3))) '@testing-library/jest-dom': specifier: ^6.1.5 version: 6.6.3 @@ -513,13 +513,13 @@ importers: version: 3.1.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) postcss: specifier: ^8.4.24 version: 8.5.5 postcss-load-config: specifier: ^4.0.1 - version: 4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + version: 4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) qrcode: specifier: ^1.5.3 version: 1.5.4 @@ -534,22 +534,22 @@ importers: version: 0.9.61(svelte@4.2.20) svelte-jester: specifier: ^3.0.0 - version: 3.0.0(jest@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))(svelte@4.2.20) + version: 3.0.0(jest@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))(svelte@4.2.20) svelte-loading-spinners: specifier: ^0.3.4 version: 0.3.6 svelte-preprocess: specifier: ^5.0.4 - version: 5.1.4(@babel/core@7.27.4)(postcss-load-config@4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))(postcss@8.5.5)(sass@1.89.2)(svelte@4.2.20)(typescript@5.8.3) + version: 5.1.4(@babel/core@7.27.4)(postcss-load-config@4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))(postcss@8.5.5)(sass@1.89.2)(svelte@4.2.20)(typescript@5.8.3) tailwindcss: specifier: ^3.3.2 - version: 3.4.17(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + version: 3.4.17(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) ts-jest: specifier: ^29.1.1 - version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))(typescript@5.8.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@24.0.1)(typescript@5.8.3) + version: 10.9.2(@types/node@22.15.31)(typescript@5.8.3) tsconfig: specifier: workspace:* version: link:../../packages/tsconfig @@ -561,7 +561,7 @@ importers: version: 5.8.3 vite: specifier: ^4.4.8 - version: 4.5.14(@types/node@24.0.1)(sass@1.89.2) + version: 4.5.14(@types/node@22.15.31)(sass@1.89.2) apps/docs: dependencies: @@ -11308,7 +11308,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -11322,7 +11322,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -11711,10 +11711,10 @@ snapshots: standardwebhooks: 1.0.0 zod: 3.25.64 - '@polar-sh/sveltekit@0.2.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))(zod@3.25.64)': + '@polar-sh/sveltekit@0.2.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))(zod@3.25.64)': dependencies: '@polar-sh/sdk': 0.20.2(zod@3.25.64) - '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) transitivePeerDependencies: - zod @@ -12341,9 +12341,9 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-auto@2.1.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))': + '@sveltejs/adapter-auto@2.1.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))': dependencies: - '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) import-meta-resolve: 4.1.0 '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.21.5(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.19(@types/node@24.0.1)(sass@1.89.2)))(svelte@4.2.20)(vite@5.4.19(@types/node@24.0.1)(sass@1.89.2)))': @@ -12361,26 +12361,26 @@ snapshots: '@sveltejs/kit': 2.21.5(@sveltejs/vite-plugin-svelte@5.1.0(svelte@5.34.1)(vite@6.3.5(@types/node@24.0.1)(jiti@1.21.7)(sass@1.89.2)(tsx@4.20.3)(yaml@2.8.0)))(svelte@5.34.1)(vite@6.3.5(@types/node@24.0.1)(jiti@1.21.7)(sass@1.89.2)(tsx@4.20.3)(yaml@2.8.0)) import-meta-resolve: 4.1.0 - '@sveltejs/adapter-node@1.3.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))': + '@sveltejs/adapter-node@1.3.1(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))': dependencies: '@rollup/plugin-commonjs': 25.0.8(rollup@3.29.5) '@rollup/plugin-json': 6.1.0(rollup@3.29.5) '@rollup/plugin-node-resolve': 15.3.1(rollup@3.29.5) - '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) rollup: 3.29.5 - '@sveltejs/adapter-vercel@3.1.0(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))(encoding@0.1.13)': + '@sveltejs/adapter-vercel@3.1.0(@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))(encoding@0.1.13)': dependencies: - '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + '@sveltejs/kit': 1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) '@vercel/nft': 0.24.4(encoding@0.1.13) esbuild: 0.18.20 transitivePeerDependencies: - encoding - supports-color - '@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2))': + '@sveltejs/kit@1.30.4(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + '@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) '@types/cookie': 0.5.4 cookie: 0.5.0 devalue: 4.3.3 @@ -12394,7 +12394,7 @@ snapshots: svelte: 4.2.20 tiny-glob: 0.2.9 undici: 5.29.0 - vite: 4.5.14(@types/node@24.0.1)(sass@1.89.2) + vite: 4.5.14(@types/node@22.15.31)(sass@1.89.2) transitivePeerDependencies: - supports-color @@ -12455,12 +12455,12 @@ snapshots: vite: 6.3.5(@types/node@24.0.1)(jiti@1.21.7)(sass@1.89.2)(tsx@4.20.3)(yaml@2.8.0) vitefu: 1.0.6(vite@6.3.5(@types/node@24.0.1)(jiti@1.21.7)(sass@1.89.2)(tsx@4.20.3)(yaml@2.8.0)) - '@sveltejs/vite-plugin-svelte-inspector@1.0.4(@sveltejs/vite-plugin-svelte@2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2))': + '@sveltejs/vite-plugin-svelte-inspector@1.0.4(@sveltejs/vite-plugin-svelte@2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + '@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) debug: 4.4.1 svelte: 4.2.20 - vite: 4.5.14(@types/node@24.0.1)(sass@1.89.2) + vite: 4.5.14(@types/node@22.15.31)(sass@1.89.2) transitivePeerDependencies: - supports-color @@ -12491,17 +12491,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2))': + '@sveltejs/vite-plugin-svelte@2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 1.0.4(@sveltejs/vite-plugin-svelte@2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)))(svelte@4.2.20)(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + '@sveltejs/vite-plugin-svelte-inspector': 1.0.4(@sveltejs/vite-plugin-svelte@2.5.3(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)))(svelte@4.2.20)(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) debug: 4.4.1 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.17 svelte: 4.2.20 svelte-hmr: 0.15.3(svelte@4.2.20) - vite: 4.5.14(@types/node@24.0.1)(sass@1.89.2) - vitefu: 0.2.5(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)) + vite: 4.5.14(@types/node@22.15.31)(sass@1.89.2) + vitefu: 0.2.5(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)) transitivePeerDependencies: - supports-color @@ -12567,11 +12567,24 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + '@tailwindcss/forms@0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) + '@tailwindcss/forms@0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))': dependencies: mini-svg-data-uri: 1.4.4 tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))': dependencies: lodash.castarray: 4.4.0 @@ -12895,6 +12908,7 @@ snapshots: '@types/node@24.0.1': dependencies: undici-types: 7.8.0 + optional: true '@types/nodemailer@6.4.17': dependencies: @@ -13978,13 +13992,13 @@ snapshots: dependencies: layout-base: 1.0.2 - create-jest@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)): + create-jest@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -15698,16 +15712,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)): + jest-cli@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + create-jest: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + jest-config: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -15717,7 +15731,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)): + jest-config@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)): dependencies: '@babel/core': 7.27.4 '@jest/test-sequencer': 29.7.0 @@ -15743,38 +15757,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.15.31 - ts-node: 10.9.2(@types/node@24.0.1)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.27.4 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.27.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 24.0.1 - ts-node: 10.9.2(@types/node@24.0.1)(typescript@5.8.3) + ts-node: 10.9.2(@types/node@22.15.31)(typescript@5.8.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -15994,12 +15977,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)): + jest@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + jest-cli: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -18351,9 +18334,9 @@ snapshots: dependencies: svelte: 5.34.1 - svelte-jester@3.0.0(jest@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))(svelte@4.2.20): + svelte-jester@3.0.0(jest@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))(svelte@4.2.20): dependencies: - jest: 29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + jest: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) svelte: 4.2.20 svelte-loading-spinners@0.3.6: {} @@ -18377,6 +18360,21 @@ snapshots: svelte: 5.34.1 tslib: 2.8.1 + svelte-preprocess@5.1.4(@babel/core@7.27.4)(postcss-load-config@4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))(postcss@8.5.5)(sass@1.89.2)(svelte@4.2.20)(typescript@5.8.3): + dependencies: + '@types/pug': 2.0.10 + detect-indent: 6.1.0 + magic-string: 0.30.17 + sorcery: 0.11.1 + strip-indent: 3.0.0 + svelte: 4.2.20 + optionalDependencies: + '@babel/core': 7.27.4 + postcss: 8.5.5 + postcss-load-config: 4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) + sass: 1.89.2 + typescript: 5.8.3 + svelte-preprocess@5.1.4(@babel/core@7.27.4)(postcss-load-config@4.0.2(postcss@8.5.5)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))(postcss@8.5.5)(sass@1.89.2)(svelte@4.2.20)(typescript@5.8.3): dependencies: '@types/pug': 2.0.10 @@ -18696,12 +18694,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@24.0.1)(ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3)) + jest: 29.7.0(@types/node@22.15.31)(ts-node@10.9.2(@types/node@22.15.31)(typescript@5.8.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -18770,7 +18768,6 @@ snapshots: typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true ts-node@10.9.2(@types/node@24.0.1)(typescript@5.8.3): dependencies: @@ -18789,6 +18786,7 @@ snapshots: typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optional: true tsc-alias@1.8.16: dependencies: @@ -18880,7 +18878,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.8.0: {} + undici-types@7.8.0: + optional: true undici@5.29.0: dependencies: @@ -19160,13 +19159,13 @@ snapshots: - supports-color - terser - vite@4.5.14(@types/node@24.0.1)(sass@1.89.2): + vite@4.5.14(@types/node@22.15.31)(sass@1.89.2): dependencies: esbuild: 0.18.20 postcss: 8.5.5 rollup: 3.29.5 optionalDependencies: - '@types/node': 24.0.1 + '@types/node': 22.15.31 fsevents: 2.3.3 sass: 1.89.2 @@ -19216,9 +19215,9 @@ snapshots: tsx: 4.20.3 yaml: 2.8.0 - vitefu@0.2.5(vite@4.5.14(@types/node@24.0.1)(sass@1.89.2)): + vitefu@0.2.5(vite@4.5.14(@types/node@22.15.31)(sass@1.89.2)): optionalDependencies: - vite: 4.5.14(@types/node@24.0.1)(sass@1.89.2) + vite: 4.5.14(@types/node@22.15.31)(sass@1.89.2) vitefu@0.2.5(vite@5.4.19(@types/node@24.0.1)(sass@1.89.2)): optionalDependencies: