feat: remove supabase dependence during course cloning (#605)
* feat: Add .gitignore file and update package.json with pnpm version feat: Enhance course queries with error handling and new course retrieval feat: Implement exercise queries for creating and fetching exercises, questions, and options feat: Create group queries for managing groups and group members feat: Add lesson queries for creating lessons and fetching sections by course ID feat: Update seed script to include seeding for users, profiles, accounts, organizations, groups, courses, lessons, exercises, questions, and options * feat: Enhance course cloning functionality by refining course and lesson data handling * chore: remove console.log * feat: update role assignment in cloneCourse function and clean up unused types in database.ts
This commit is contained in:
@@ -1,295 +1,136 @@
|
||||
import { getSupabase } from '@api/utils/supabase';
|
||||
import type { Course, Lesson, LessonSection, GroupMember } from '@api/types/database';
|
||||
import type { PostgrestError } from '@supabase/supabase-js';
|
||||
|
||||
const supabase = getSupabase();
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import {
|
||||
addGroupMember,
|
||||
createCourse,
|
||||
createGroup,
|
||||
createLessons,
|
||||
createLessonSections,
|
||||
getCourseById,
|
||||
getLessonsByCourseId,
|
||||
getSectionsByCourseId,
|
||||
getLessonLanguagesByLessonIds,
|
||||
createLessonLanguages,
|
||||
getExercisesByLessonIds,
|
||||
createExercises,
|
||||
getQuestionsByExerciseIds,
|
||||
createQuestions,
|
||||
getOptionsByQuestionIds,
|
||||
createOptions
|
||||
} from '@db/queries';
|
||||
import { TCourse, TLesson, TLessonSection } from '@db/types';
|
||||
|
||||
// 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)
|
||||
`;
|
||||
async function cloneLessonLanguages(newLessons: TLesson[], oldLessons: TLesson[]): Promise<void> {
|
||||
// Extract old lesson IDs
|
||||
const oldLessonIds = oldLessons.map((lesson) => lesson.id);
|
||||
|
||||
interface FetchCourseDataResponse {
|
||||
data: Course | null;
|
||||
error: PostgrestError | null;
|
||||
}
|
||||
// Fetch all lesson languages for the old lessons
|
||||
const oldLessonLanguages = await getLessonLanguagesByLessonIds(oldLessonIds);
|
||||
|
||||
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 (!oldLessonLanguages || oldLessonLanguages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
// Create a map of old lesson ID to new lesson ID
|
||||
const lessonIdMap = new Map<string, string>();
|
||||
oldLessons.forEach((oldLesson, index) => {
|
||||
lessonIdMap.set(oldLesson.id, newLessons[index].id);
|
||||
});
|
||||
|
||||
return {
|
||||
newCourse: newCourse[0]
|
||||
};
|
||||
// Map lesson languages to new lesson IDs
|
||||
const newLessonLanguages = oldLessonLanguages.map((lang) => ({
|
||||
content: lang.content,
|
||||
locale: lang.locale,
|
||||
lessonId: lessonIdMap.get(lang.lessonId!)!
|
||||
}));
|
||||
|
||||
// Bulk insert lesson languages
|
||||
await createLessonLanguages(newLessonLanguages);
|
||||
}
|
||||
|
||||
async function cloneLessonSections(sections: LessonSection[], courseId: string): Promise<CloneSectionsResponse> {
|
||||
if (!sections || sections.length === 0) {
|
||||
return { newSections: [], sectionIdMap: undefined };
|
||||
async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Promise<void> {
|
||||
// 1. Extract old lesson IDs
|
||||
const oldLessonIds = oldLessons.map((lesson) => lesson.id);
|
||||
|
||||
// 2. Fetch all exercises for old lessons
|
||||
const oldExercises = await getExercisesByLessonIds(oldLessonIds);
|
||||
|
||||
if (!oldExercises || oldExercises.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// 3. Create lesson ID map
|
||||
const lessonIdMap = new Map<string, string>();
|
||||
oldLessons.forEach((oldLesson, index) => {
|
||||
lessonIdMap.set(oldLesson.id, newLessons[index].id);
|
||||
});
|
||||
|
||||
return { newSections: newSections || [], sectionIdMap };
|
||||
}
|
||||
// 4. Insert exercises with new lesson IDs
|
||||
const newExercises = await createExercises(
|
||||
oldExercises.map((exercise) => ({
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
dueBy: new Date().toISOString(),
|
||||
lessonId: lessonIdMap.get(exercise.lessonId!)!
|
||||
}))
|
||||
);
|
||||
|
||||
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
|
||||
};
|
||||
// 5. Create exercise ID map
|
||||
const exerciseIdMap = new Map<string, string>();
|
||||
oldExercises.forEach((oldExercise, index) => {
|
||||
exerciseIdMap.set(oldExercise.id, newExercises[index].id);
|
||||
});
|
||||
|
||||
return { newLessons };
|
||||
}
|
||||
// 6. Fetch all questions for old exercises
|
||||
const oldExerciseIds = oldExercises.map((ex) => ex.id);
|
||||
const oldQuestions = await getQuestionsByExerciseIds(oldExerciseIds);
|
||||
|
||||
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;
|
||||
}
|
||||
if (!oldQuestions || oldQuestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
// 7. Insert questions with new exercise IDs
|
||||
const newQuestions = await createQuestions(
|
||||
oldQuestions.map((question) => ({
|
||||
name: question.name,
|
||||
title: question.title,
|
||||
points: question.points,
|
||||
order: question.order,
|
||||
questionTypeId: question.questionTypeId,
|
||||
exerciseId: exerciseIdMap.get(question.exerciseId)!
|
||||
}))
|
||||
);
|
||||
|
||||
async function cloneExercises(lessons: Lesson[]): Promise<boolean> {
|
||||
// Clone exercises, questions, and options
|
||||
for (const lesson of lessons) {
|
||||
const { id: lessonId, exercise = [] } = lesson;
|
||||
// 8. Create question ID map
|
||||
const questionIdMap = new Map<number, number>();
|
||||
oldQuestions.forEach((oldQuestion, index) => {
|
||||
questionIdMap.set(oldQuestion.id, newQuestions[index].id);
|
||||
});
|
||||
|
||||
// Exercises
|
||||
for (const exerciseItem of exercise) {
|
||||
const { title, description, questions = [] } = exerciseItem;
|
||||
// 9. Fetch all options for old questions (except for paragraph type questions)
|
||||
const oldQuestionIds = oldQuestions.filter((q) => q.questionTypeId !== QUESTION_TYPE_TEXTAREA).map((q) => q.id);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldQuestionIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return true;
|
||||
const oldOptions = await getOptionsByQuestionIds(oldQuestionIds);
|
||||
|
||||
if (!oldOptions || oldOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 10. Insert options with new question IDs
|
||||
await createOptions(
|
||||
oldOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
isCorrect: option.isCorrect,
|
||||
questionId: questionIdMap.get(option.questionId)!
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function cloneCourse(
|
||||
@@ -299,63 +140,93 @@ export async function cloneCourse(
|
||||
newDescription?: string,
|
||||
newSlug?: string,
|
||||
organizationId?: string
|
||||
): Promise<Course> {
|
||||
const { data: course, error } = await fetchCourseData(courseId);
|
||||
): Promise<TCourse> {
|
||||
// 1. fetch old course
|
||||
const [course] = await getCourseById(courseId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error when cloning', error);
|
||||
throw error;
|
||||
// 2. create group
|
||||
const [newGroup] = await createGroup({
|
||||
name: newTitle,
|
||||
description: newDescription ?? course.description,
|
||||
organizationId: organizationId
|
||||
});
|
||||
|
||||
// 3. create course for that group
|
||||
// Explicitly select only the fields we want to copy (exclude id, createdAt, updatedAt)
|
||||
const [newCourse] = await createCourse({
|
||||
title: newTitle,
|
||||
description: course.description,
|
||||
overview: course.overview,
|
||||
groupId: newGroup.id,
|
||||
isTemplate: course.isTemplate,
|
||||
logo: course.logo,
|
||||
slug: newSlug ?? null,
|
||||
metadata: course.metadata,
|
||||
cost: course.cost,
|
||||
currency: course.currency,
|
||||
bannerImage: course.bannerImage,
|
||||
isPublished: course.isPublished,
|
||||
isCertificateDownloadable: course.isCertificateDownloadable,
|
||||
certificateTheme: course.certificateTheme,
|
||||
status: course.status,
|
||||
type: course.type,
|
||||
version: course.version
|
||||
});
|
||||
|
||||
// 4. add group member
|
||||
await addGroupMember({
|
||||
profileId: userId,
|
||||
email: '',
|
||||
groupId: newGroup.id,
|
||||
roleId: ROLE.TUTOR
|
||||
});
|
||||
|
||||
// 5. clone sections
|
||||
const oldSections = await getSectionsByCourseId(courseId);
|
||||
|
||||
// Only create sections if there are any
|
||||
let newSections: TLessonSection[] = [];
|
||||
let sectionMap = new Map<string, string>();
|
||||
|
||||
if (oldSections.length > 0) {
|
||||
newSections = await createLessonSections(
|
||||
oldSections.map((sec) => ({
|
||||
title: sec.title,
|
||||
order: sec.order,
|
||||
courseId: newCourse.id
|
||||
}))
|
||||
);
|
||||
|
||||
// create map
|
||||
sectionMap = new Map(oldSections.map((old, i) => [old.id, newSections[i].id]));
|
||||
}
|
||||
|
||||
if (!course) {
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
// 6. clone lessons
|
||||
const oldLessons = await getLessonsByCourseId(courseId);
|
||||
|
||||
// 1. Clone course and group
|
||||
const { newCourse } = await cloneGroupAndBasicCourse(
|
||||
course,
|
||||
newTitle,
|
||||
userId,
|
||||
newDescription,
|
||||
newSlug,
|
||||
organizationId
|
||||
const newLessons = await createLessons(
|
||||
oldLessons.map((lesson) => ({
|
||||
note: lesson.note,
|
||||
videoUrl: lesson.videoUrl,
|
||||
slideUrl: lesson.slideUrl,
|
||||
courseId: newCourse.id,
|
||||
title: lesson.title,
|
||||
public: lesson.public,
|
||||
lessonAt: lesson.lessonAt,
|
||||
teacherId: lesson.teacherId,
|
||||
isComplete: lesson.isComplete,
|
||||
callUrl: lesson.callUrl,
|
||||
order: lesson.order,
|
||||
isUnlocked: lesson.isUnlocked,
|
||||
videos: lesson.videos,
|
||||
sectionId: lesson.sectionId ? sectionMap.get(lesson.sectionId) : null,
|
||||
documents: lesson.documents
|
||||
}))
|
||||
);
|
||||
|
||||
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 || []);
|
||||
// 7. clone languages, exercises, questions, options using bulk queries
|
||||
await cloneLessonLanguages(newLessons, oldLessons);
|
||||
await cloneExercises(newLessons, oldLessons);
|
||||
|
||||
return newCourse;
|
||||
}
|
||||
|
||||
@@ -1,145 +1,12 @@
|
||||
// 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;
|
||||
}
|
||||
import { TCourse } from '@db/types';
|
||||
|
||||
// Response types
|
||||
export interface CloneCourseResponse {
|
||||
success: boolean;
|
||||
course: Course;
|
||||
course: TCourse;
|
||||
}
|
||||
|
||||
export interface CloneCourseErrorResponse {
|
||||
error: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user