Files
classroomio/packages/db/src/queries/exercise/exercise.ts
T
2026-04-27 11:40:38 +01:00

550 lines
19 KiB
TypeScript

import * as schema from '@db/schema';
import type {
TExercise,
TExercise as TExerciseType,
TNewExercise,
TNewOption,
TNewQuestion,
TQuestionType
} from '@db/types';
import { and, asc, db, eq, inArray, isNull, or, sql } from '@db/drizzle';
import type { DbOrTxClient } from '@db/drizzle';
export async function createExercises(values: TNewExercise[], dbClient: DbOrTxClient = db) {
try {
return dbClient.insert(schema.exercise).values(values).returning();
} catch (error) {
console.error('createExercises error:', error);
throw new Error(`Failed to create exercises: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExerciseById(exerciseId: string, dbClient: DbOrTxClient = db): Promise<TExercise | null> {
try {
const [exercise] = await dbClient.select().from(schema.exercise).where(eq(schema.exercise.id, exerciseId)).limit(1);
return exercise || null;
} catch (error) {
console.error('getExerciseById error:', error);
throw new Error(
`Failed to get exercise by ID "${exerciseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Checks if a profile has completed an exercise (submission exists)
*/
export async function getExerciseCompletionByProfile(
exerciseId: string,
profileId: string,
dbClient: DbOrTxClient = db
): Promise<boolean> {
try {
const result = await dbClient
.select({ exerciseId: schema.submission.exerciseId })
.from(schema.submission)
.innerJoin(schema.groupmember, eq(schema.submission.submittedBy, schema.groupmember.id))
.where(and(eq(schema.submission.exerciseId, exerciseId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
return result.length > 0;
} catch (error) {
console.error('getExerciseCompletionByProfile error:', error);
throw new Error(
`Failed to get exercise completion for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets completed exercise IDs for a profile (submission exists)
*/
export async function getExerciseCompletionsByProfile(
exerciseIds: string[],
profileId: string,
dbClient: DbOrTxClient = db
): Promise<string[]> {
if (exerciseIds.length === 0) return [];
try {
const result = await dbClient
.select({ exerciseId: schema.submission.exerciseId })
.from(schema.submission)
.innerJoin(schema.groupmember, eq(schema.submission.submittedBy, schema.groupmember.id))
.where(and(inArray(schema.submission.exerciseId, exerciseIds), eq(schema.groupmember.profileId, profileId)));
return result.map((row) => row.exerciseId);
} catch (error) {
console.error('getExerciseCompletionsByProfile error:', error);
throw new Error(
`Failed to get exercise completions for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getExercisesByLessonIds(lessonIds: string[], dbClient: DbOrTxClient = db): Promise<TExercise[]> {
if (lessonIds.length === 0) return [];
try {
return dbClient.select().from(schema.exercise).where(inArray(schema.exercise.lessonId, lessonIds));
} catch (error) {
console.error('getExercisesByLessonIds error:', error);
throw new Error(
`Failed to get exercises by lesson IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getExercisesByCourseId(
courseId: string,
filters: { lessonId?: string; sectionId?: string } = {},
dbClient: DbOrTxClient = db
): Promise<TExercise[]> {
try {
const { lessonId, sectionId } = filters;
const conditions = [or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))];
if (lessonId) {
conditions.push(eq(schema.exercise.lessonId, lessonId));
}
if (sectionId) {
conditions.push(or(eq(schema.exercise.sectionId, sectionId), eq(schema.lesson.sectionId, sectionId)));
}
const results = await dbClient
.select({
exercise: schema.exercise,
sectionId: schema.lesson.sectionId,
lessonCourseId: schema.lesson.courseId
})
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(and(...conditions));
return results.map((row) => ({
...row.exercise,
courseId: row.exercise.courseId ?? row.lessonCourseId ?? row.exercise.courseId,
sectionId: row.exercise.sectionId ?? row.sectionId
}));
} catch (error) {
console.error('getExercisesByCourseId error:', error);
throw new Error(
`Failed to get exercises by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateExercise(exerciseId: string, data: Partial<TExerciseType>, dbClient: DbOrTxClient = db) {
try {
const [updated] = await dbClient
.update(schema.exercise)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.exercise.id, exerciseId))
.returning();
return updated || null;
} catch (error) {
console.error('updateExercise error:', error);
throw new Error(
`Failed to update exercise "${exerciseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateExercisesSectionId(
courseId: string,
sectionId: string,
dbClient: DbOrTxClient = db
): Promise<number> {
try {
const updated = await dbClient
.update(schema.exercise)
.set({ sectionId })
.where(eq(schema.exercise.courseId, courseId))
.returning();
return updated.length;
} catch (error) {
console.error('updateExercisesSectionId error:', error);
throw new Error(
`Failed to update exercises section ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateUngroupedExercisesSectionId(
courseId: string,
sectionId: string,
dbClient: DbOrTxClient = db
): Promise<number> {
try {
const updated = await dbClient
.update(schema.exercise)
.set({ sectionId })
.where(and(eq(schema.exercise.courseId, courseId), isNull(schema.exercise.sectionId)))
.returning();
return updated.length;
} catch (error) {
console.error('updateUngroupedExercisesSectionId error:', error);
throw new Error(
`Failed to update ungrouped exercises section ID "${courseId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function deleteExercise(exerciseId: string, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.exercise).where(eq(schema.exercise.id, exerciseId)).returning();
console.log('Deleted exercise:', deleted);
return deleted || null;
} catch (error) {
console.error('deleteExercise error:', error);
throw new Error(
`Failed to delete exercise "${exerciseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createQuestions(values: TNewQuestion[], dbClient: DbOrTxClient = db) {
if (values.length === 0) return [];
try {
// Keep the identity sequence aligned before bulk inserts so restored data
// or manual imports cannot reuse an existing question id.
await syncQuestionIdSequence(dbClient);
return dbClient.insert(schema.question).values(values).returning();
} catch (error) {
const err = error as Error & { code?: string; cause?: unknown };
console.error('createQuestions error:', {
message: err.message,
code: err.code,
cause: err.cause
});
throw new Error('Failed to create questions');
}
}
export async function getQuestionsByExerciseIds(
exerciseIds: string[],
dbClient: DbOrTxClient = db
): Promise<TNewQuestion[]> {
if (exerciseIds.length === 0) return [];
try {
return dbClient.select().from(schema.question).where(inArray(schema.question.exerciseId, exerciseIds));
} catch (error) {
console.error('getQuestionsByExerciseIds error:', error);
throw new Error(
`Failed to get questions by exercise IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateQuestion(questionId: number, data: Partial<TNewQuestion>, dbClient: DbOrTxClient = db) {
try {
const [updated] = await dbClient
.update(schema.question)
.set(data)
.where(eq(schema.question.id, questionId))
.returning();
return updated || null;
} catch (error) {
console.error('updateQuestion error:', error);
throw new Error(
`Failed to update question "${questionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteQuestion(questionId: number, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.question).where(eq(schema.question.id, questionId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteQuestion error:', error);
throw new Error(
`Failed to delete question "${questionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createOptions(values: TNewOption[], dbClient: DbOrTxClient = db) {
if (values.length === 0) return [];
try {
// Keep the identity sequence aligned before bulk inserts so restored data
// or manual imports cannot reuse an existing option id.
await syncOptionIdSequence(dbClient);
return dbClient.insert(schema.option).values(values).returning();
} catch (error) {
console.error('createOptions error:', error);
throw new Error(`Failed to create options: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getOptionsByQuestionIds(questionIds: number[], dbClient: DbOrTxClient = db) {
if (questionIds.length === 0) return [];
try {
return dbClient
.select()
.from(schema.option)
.where(inArray(schema.option.questionId, questionIds))
.orderBy(asc(schema.option.questionId), asc(schema.option.id));
} catch (error) {
console.error('getOptionsByQuestionIds error:', error);
throw new Error(
`Failed to get options by question IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateOption(optionId: number, data: Partial<TNewOption>, dbClient: DbOrTxClient = db) {
try {
const [updated] = await dbClient.update(schema.option).set(data).where(eq(schema.option.id, optionId)).returning();
return updated || null;
} catch (error) {
console.error('updateOption error:', error);
throw new Error(
`Failed to update option "${optionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteOption(optionId: number, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.option).where(eq(schema.option.id, optionId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteOption error:', error);
throw new Error(
`Failed to delete option "${optionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Deletes all options for given question IDs in a single query
*/
export async function deleteOptionsByQuestionIds(questionIds: number[], dbClient: DbOrTxClient = db) {
if (questionIds.length === 0) return [];
try {
return dbClient.delete(schema.option).where(inArray(schema.option.questionId, questionIds)).returning();
} catch (error) {
console.error('deleteOptionsByQuestionIds error:', error);
throw new Error(
`Failed to delete options by question IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Deletes questions by IDs in a single query
*/
export async function deleteQuestionsByIds(questionIds: number[], dbClient: DbOrTxClient = db) {
if (questionIds.length === 0) return [];
try {
return dbClient.delete(schema.question).where(inArray(schema.question.id, questionIds)).returning();
} catch (error) {
console.error('deleteQuestionsByIds error:', error);
throw new Error(`Failed to delete questions by IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Deletes options by IDs in a single query
*/
export async function deleteOptionsByIds(optionIds: number[], dbClient: DbOrTxClient = db) {
if (optionIds.length === 0) return [];
try {
return dbClient.delete(schema.option).where(inArray(schema.option.id, optionIds)).returning();
} catch (error) {
console.error('deleteOptionsByIds error:', error);
throw new Error(`Failed to delete options by IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Batch update options - updates multiple options in parallel using a single transaction
*/
export async function updateOptions(
updates: Array<{ id: number; data: Partial<TNewOption> }>,
dbClient?: DbOrTxClient
) {
if (updates.length === 0) return [];
try {
const run = (client: DbOrTxClient) =>
Promise.all(
updates.map(({ id, data }) =>
client
.update(schema.option)
.set(data)
.where(eq(schema.option.id, id))
.returning()
.then((r) => r[0])
)
);
const results = dbClient ? await run(dbClient) : await db.transaction((tx) => run(tx));
return results.filter((r) => r !== undefined);
} catch (error) {
console.error('batchUpdateOptions error:', error);
throw new Error(`Failed to batch update options: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Batch update questions - updates multiple questions in parallel using a single transaction
*/
export async function updateQuestions(
updates: Array<{ id: number; data: Partial<TNewQuestion> }>,
dbClient?: DbOrTxClient
) {
if (updates.length === 0) return [];
try {
const run = (client: DbOrTxClient) =>
Promise.all(
updates.map(({ id, data }) =>
client
.update(schema.question)
.set(data)
.where(eq(schema.question.id, id))
.returning()
.then((r) => r[0])
)
);
const results = dbClient ? await run(dbClient) : await db.transaction((tx) => run(tx));
return results.filter((r) => r !== undefined);
} catch (error) {
console.error('batchUpdateQuestions error:', error);
throw new Error(`Failed to batch update questions: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets question types by IDs
* @param ids Array of question type IDs
* @returns Array of question types
*/
export async function getQuestionTypesByIds(ids: number[], dbClient: DbOrTxClient = db): Promise<TQuestionType[]> {
if (ids.length === 0) return [];
try {
return dbClient.select().from(schema.questionType).where(inArray(schema.questionType.id, ids));
} catch (error) {
console.error('getQuestionTypesByIds error:', error);
throw new Error(`Failed to get question types by IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Optimized: Fetches exercise with questions and question types in a single query using JOINs
* @param exerciseId Exercise ID
* @returns Exercise with questions (including questionType) and options separately
*/
export async function getExerciseWithRelationsOptimized(
exerciseId: string,
dbClient: DbOrTxClient = db
): Promise<{
exercise: TExercise;
questions: (TNewQuestion & { questionType: TQuestionType; options: TNewOption[] })[];
}> {
try {
const result = await dbClient
.select({
exercise: schema.exercise,
question: schema.question,
questionType: schema.questionType,
option: schema.option
})
.from(schema.exercise)
.leftJoin(schema.question, eq(schema.question.exerciseId, schema.exercise.id))
.leftJoin(schema.questionType, eq(schema.question.questionTypeId, schema.questionType.id))
.leftJoin(schema.option, eq(schema.option.questionId, schema.question.id))
.where(eq(schema.exercise.id, exerciseId));
if (result.length === 0 || !result[0].exercise) {
throw new Error(`Exercise not found: ${exerciseId}`);
}
const exercise = result[0].exercise;
// Group questions with their question types and options (deduplicate by question ID, collect options)
const questionsMap = new Map<number, TNewQuestion & { questionType: TQuestionType; options: TNewOption[] }>();
for (const row of result) {
if (row.question && row.questionType) {
if (!questionsMap.has(row.question.id)) {
questionsMap.set(row.question.id, {
...row.question,
questionType: row.questionType,
options: []
});
}
// Add option if it exists and hasn't been added yet
if (row.option && row.option.id) {
const question = questionsMap.get(row.question.id)!;
const optionId = row.option.id;
// Check if option already added (avoid duplicates from JOIN)
if (!question.options.some((opt) => opt.id === optionId)) {
question.options.push(row.option);
}
}
}
}
// Sort options by id so order matches insertion order (order sent from client)
for (const question of questionsMap.values()) {
question.options.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
}
const questions = Array.from(questionsMap.values()).sort((a, b) => {
const orderA = a.order ?? 0;
const orderB = b.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return (a.id ?? 0) - (b.id ?? 0);
});
return {
exercise,
questions
};
} catch (error) {
console.error('getExerciseWithRelations error:', error);
throw new Error(
`Failed to get exercise with relations: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Syncs the option table's identity sequence to the current max(id).
* Call before inserting options to avoid duplicate key on option_pkey when the sequence is behind.
*/
export async function syncOptionIdSequence(dbClient: DbOrTxClient = db): Promise<void> {
await dbClient.execute(
sql`
SELECT setval(
pg_get_serial_sequence('option', 'id'),
COALESCE((SELECT MAX(id) FROM "option"), 1),
EXISTS(SELECT 1 FROM "option")
)
`
);
}
/**
* Syncs the question table's identity sequence to the current max(id).
* Call before inserting questions to avoid duplicate key on question_pkey when the sequence is behind.
*/
export async function syncQuestionIdSequence(dbClient: DbOrTxClient = db): Promise<void> {
await dbClient.execute(
sql`
SELECT setval(
pg_get_serial_sequence('question', 'id'),
COALESCE((SELECT MAX(id) FROM question), 1),
EXISTS(SELECT 1 FROM question)
)
`
);
}