diff --git a/apps/backend/src/routes/questions/route.ts b/apps/backend/src/routes/questions/route.ts index f0bee0f..57117fb 100644 --- a/apps/backend/src/routes/questions/route.ts +++ b/apps/backend/src/routes/questions/route.ts @@ -12,13 +12,22 @@ import checkPermission from "../../middlewares/checkPermission"; import { aspects } from "../../drizzle/schema/aspects"; import { subAspects } from "../../drizzle/schema/subAspects"; import { notFound } from "../../errors/DashboardError"; +import { options } from "../../drizzle/schema/options"; +// Schema for creating and updating options +export const optionFormSchema = z.object({ + text: z.string().min(1).max(255), + score: z.number().min(0), +}); + +// Schema for creating and updating questions export const questionFormSchema = z.object({ subAspectId: z.string().min(1).max(255), question: z.string().min(1).max(255), needFile: z.boolean().default(false), + options: z.array(optionFormSchema).optional(), // Allow options to be included }); - + export const questionUpdateSchema = questionFormSchema.extend({ question: z.string().min(1).max(255).or(z.literal("")), subAspectId: z.string().min(1).max(255).or(z.literal("")), @@ -27,6 +36,59 @@ export const questionUpdateSchema = questionFormSchema.extend({ const questionsRoute = new Hono() .use(authInfo) + /** + * Get All Aspects + */ + .get("/aspects", + checkPermission("questions.readAll"), + async (c) => { + const result = await db + .select({ + id: aspects.id, + name: aspects.name, + createdAt: aspects.createdAt, + updatedAt: aspects.updatedAt, + }) + .from(aspects); + + return c.json(result); + }) + /** + * Get All Sub Aspects + * + * Query params: + * - aspectId: string (optional) + */ + .get("/subAspects", + checkPermission("questions.readAll"), + requestValidator( + "query", + z.object({ + aspectId: z.string().optional(), + }) + ), + async (c) => { + const { aspectId } = c.req.valid("query"); + + const query = db + .select({ + id: subAspects.id, + name: subAspects.name, + aspectId: subAspects.aspectId, + createdAt: subAspects.createdAt, + updatedAt: subAspects.updatedAt, + }) + .from(subAspects); + + if (aspectId) { + query.where(eq(subAspects.aspectId, aspectId)); + } + + const result = await query; + + return c.json(result); + } + ) /** * Get All Questions (With Metadata) * @@ -70,6 +132,12 @@ const questionsRoute = new Hono() createdAt: questions.createdAt, updatedAt: questions.updatedAt, ...(includeTrashed ? { deletedAt: questions.deletedAt } : {}), + averageScore: sql`( + SELECT ROUND(AVG(${options.score}), 2) + FROM ${options} + WHERE ${options.questionId} = ${questions.id} + AND ${options.deletedAt} IS NULL -- Include only non-deleted options + )`, fullCount: totalCountQuery, }) .from(questions) @@ -80,14 +148,15 @@ const questionsRoute = new Hono() includeTrashed ? undefined : isNull(questions.deletedAt), q ? or( - ilike(questions.createdAt, q), - ilike(questions.updatedAt, q), - ilike(questions.deletedAt, q), + ilike(questions.question, `%${q}%`), + ilike(aspects.name, `%${q}%`), + ilike(subAspects.name, `%${q}%`), eq(questions.id, q) ) : undefined ) ) + .orderBy(questions.question) .offset(page * limit) .limit(limit); @@ -123,6 +192,11 @@ const questionsRoute = new Hono() async (c) => { const questionId = c.req.param("id"); + if (!questionId) + throw notFound({ + message: "Missing id", + }); + const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true"; @@ -131,26 +205,53 @@ const questionsRoute = new Hono() id: questions.id, question: questions.question, needFile: questions.needFile, - aspectId: aspects.id, - subAspectId: subAspects.id, + subAspectId: questions.subAspectId, + subAspectName: subAspects.name, + aspectId: subAspects.aspectId, + aspectName: aspects.name, createdAt: questions.createdAt, updatedAt: questions.updatedAt, ...(includeTrashed ? { deletedAt: questions.deletedAt } : {}), + options: { + id: options.id, + text: options.text, + score: options.score, + }, }) .from(questions) .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .leftJoin(options, and(eq(questions.id, options.questionId), isNull(options.deletedAt))) // Filter out soft-deleted options .where( and( eq(questions.id, questionId), !includeTrashed ? isNull(questions.deletedAt) : undefined ) - ); + ) + .groupBy(questions.id, questions.question, questions.needFile, subAspects.aspectId, + aspects.name, questions.subAspectId, subAspects.name, questions.createdAt, + questions.updatedAt, options.id, options.text, options.score); if (!queryResult[0]) throw notFound(); + const optionsList = queryResult.reduce((prev, curr) => { + if (!curr.options) return prev; + prev.set(curr.options.id, + { + text: curr.options.text, + score: curr.options.score, + } + ); + return prev; + }, new Map()); + + // Convert Map to Array and sort by the score field in ascending order + const sortedOptions = Array.from(optionsList, ([id, { text, score }]) => ({ id, text, score })) + .sort((a, b) => a.score - b.score); // Sort based on score field in ascending order + const questionData = { ...queryResult[0], + options: sortedOptions, }; return c.json(questionData); @@ -168,20 +269,44 @@ const questionsRoute = new Hono() requestValidator("json", questionFormSchema), async (c) => { const questionData = c.req.valid("json"); - + + // Check if the sub aspect exists + const existingSubAspect = await db + .select() + .from(subAspects) + .where(eq(subAspects.id, questionData.subAspectId)); + + if (existingSubAspect.length === 0) { + return c.json({ message: "Sub aspect not found" }, 404); + } + + // Insert question data into the questions table const question = await db .insert(questions) .values({ - question: questionData.question, - needFile: questionData.needFile, - subAspectId: questionData.subAspectId, + question: questionData.question, + needFile: questionData.needFile, + subAspectId: questionData.subAspectId, }) .returning(); - + + const questionId = question[0].id; + + // Insert options data if provided + if (questionData.options && questionData.options.length > 0) { + const optionsData = questionData.options.map((option) => ({ + questionId: questionId, + text: option.text, + score: option.score, + })); + + await db.insert(options).values(optionsData); + } + return c.json( { - message: "Question created successfully", - data: question, + message: "Question and options created successfully", + data: question[0], }, 201 ); @@ -200,14 +325,16 @@ const questionsRoute = new Hono() async (c) => { const questionId = c.req.param("id"); const questionData = c.req.valid("json"); - + + // Check if the question exists and is not soft deleted const question = await db .select() .from(questions) .where(and(eq(questions.id, questionId), isNull(questions.deletedAt))); - + if (!question[0]) throw notFound(); - + + // Update question data await db .update(questions) .set({ @@ -215,9 +342,59 @@ const questionsRoute = new Hono() updatedAt: new Date(), }) .where(eq(questions.id, questionId)); - + + // Check if options data is provided + if (questionData.options !== undefined) { + // Fetch existing options from the database for this question + const existingOptions = await db + .select() + .from(options) + .where(and(eq(options.questionId, questionId), isNull(options.deletedAt))); + + // Prepare new options data for comparison + const newOptionsData = questionData.options.map((option) => ({ + questionId: questionId, + text: option.text, + score: option.score, + })); + + // Iterate through existing options and perform updates or soft deletes if needed + for (const existingOption of existingOptions) { + const matchingOption = newOptionsData.find( + (newOption) => newOption.text === existingOption.text + ); + + if (!matchingOption) { + // If the existing option is not in the new options data, soft delete it + await db + .update(options) + .set({ deletedAt: new Date() }) + .where(eq(options.id, existingOption.id)); + } else { + // If the option is found, update it if the score has changed + if (existingOption.score !== matchingOption.score) { + await db + .update(options) + .set({ + score: matchingOption.score, + updatedAt: new Date(), + }) + .where(eq(options.id, existingOption.id)); + } + } + } + + // Insert new options that do not exist in the database + const existingOptionTexts = existingOptions.map((opt) => opt.text); + const optionsToInsert = newOptionsData.filter((newOption) => !existingOptionTexts.includes(newOption.text)); + + if (optionsToInsert.length > 0) { + await db.insert(options).values(optionsToInsert); + } + } + return c.json({ - message: "Question updated successfully", + message: "Question and options updated successfully", }); } )