import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { HTTPException } from "hono/http-exception"; import db from "../../drizzle"; import { questions } from "../../drizzle/schema/questions"; import HonoEnv from "../../types/HonoEnv"; import requestValidator from "../../utils/requestValidator"; import authInfo from "../../middlewares/authInfo"; 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).max(999), }); // Schema for creating and updating questions export const questionFormSchema = z.object({ subAspectId: z.string().min(1).max(255), question: z.string().min(1).max(510), 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(510).or(z.literal("")), subAspectId: z.string().min(1).max(255).or(z.literal("")), needFile: z.boolean().default(false).or(z.boolean()), }); 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) * * Query params: * - includeTrashed: boolean (default: false) * - withMetadata: boolean */ .get( "/", checkPermission("questions.readAll"), requestValidator( "query", z.object({ includeTrashed: z .string() .optional() .transform((v) => v?.toLowerCase() === "true"), withMetadata: z .string() .optional() .transform((v) => v?.toLowerCase() === "true"), page: z.coerce.number().int().min(0).default(0), limit: z.coerce.number().int().min(1).max(1000).default(30), q: z.string().default(""), }) ), async (c) => { const { includeTrashed, page, limit, q } = c.req.valid("query"); const totalCountQuery = includeTrashed ? sql`(SELECT count(*) FROM ${questions})` : sql`(SELECT count(*) FROM ${questions} WHERE ${questions.deletedAt} IS NULL)`; const result = await db .select({ id: questions.id, question: questions.question, needFile: questions.needFile, aspectName: aspects.name, subAspectName: subAspects.name, 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) .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) .where( and( includeTrashed ? undefined : isNull(questions.deletedAt), q ? or( 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); return c.json({ data: result.map((d) => ({ ...d, fullCount: undefined })), _metadata: { currentPage: page, totalPages: Math.ceil( (Number(result[0]?.fullCount) ?? 0) / limit ), totalItems: Number(result[0]?.fullCount) ?? 0, perPage: limit, }, }); } ) /** * Get Question by ID * * Query params: * - id: string * - includeTrashed: boolean (default: false) */ .get( "/:id", checkPermission("questions.readAll"), requestValidator( "query", z.object({ includeTrashed: z.string().default("false"), }) ), async (c) => { const questionId = c.req.param("id"); if (!questionId) throw notFound({ message: "Missing id", }); const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true"; const queryResult = await db .select({ id: questions.id, question: questions.question, needFile: questions.needFile, 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); } ) /** * Create Question * * JSON: * - questionFormSchema: object */ .post( "/", checkPermission("questions.create"), 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, }) .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 and options created successfully", data: question[0], }, 201 ); } ) /** * Update Question * * JSON: * - questionUpdateSchema: object */ .patch( "/:id", checkPermission("questions.update"), requestValidator("json", questionUpdateSchema), 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({ ...questionData, 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 and options updated successfully", }); } ) /** * Delete Question * * Query params: * - id: string * - skipTrash: string (default: false) */ .delete( "/:id", checkPermission("questions.delete"), requestValidator( "query", z.object({ skipTrash: z.string().default("false"), }) ), async (c) => { const questionId = c.req.param("id"); const skipTrash = c.req.valid("query").skipTrash.toLowerCase() === "true"; const question = await db .select() .from(questions) .where( and( eq(questions.id, questionId), skipTrash ? undefined : isNull(questions.deletedAt) ) ); if (!question[0]) throw notFound(); if (skipTrash) { await db.delete(questions).where(eq(questions.id, questionId)); } else { await db .update(questions) .set({ deletedAt: new Date(), }) .where(and(eq(questions.id, questionId), isNull(questions.deletedAt))); } return c.json({ message: "Question deleted successfully", }); } ) /** * Restore Question * * Query params: * - id: string */ .patch("/restore/:id", checkPermission("questions.restore"), async (c) => { const questionId = c.req.param("id"); const question = ( await db.select().from(questions).where(eq(questions.id, questionId)) )[0]; if (!question) throw notFound(); if (!question.deletedAt) { throw new HTTPException(400, { message: "The question is not deleted", }); } await db .update(questions) .set({ deletedAt: null }) .where(eq(questions.id, questionId)); return c.json({ message: "Question restored successfully", }); }); export default questionsRoute;