import { and, eq, ilike, isNull, inArray, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import db from "../../drizzle"; import { answers } from "../../drizzle/schema/answers"; import { options } from "../../drizzle/schema/options"; import { questions } from "../../drizzle/schema/questions"; import { subAspects } from "../../drizzle/schema/subAspects"; import { aspects } from "../../drizzle/schema/aspects"; import { assessments } from "../../drizzle/schema/assessments"; import HonoEnv from "../../types/HonoEnv"; import requestValidator from "../../utils/requestValidator"; import authInfo from "../../middlewares/authInfo"; import checkPermission from "../../middlewares/checkPermission"; import path from "path"; import fs from 'fs'; import { notFound } from "../../errors/DashboardError"; export const answerFormSchema = z.object({ optionId: z.string().min(1), assessmentId: z.string().min(1), isFlagged: z.boolean().optional().default(false), filename: z.string().optional(), validationInformation: z.string().min(1), }); export const answerUpdateSchema = answerFormSchema.partial(); // Helper function to save the file async function saveFile(filePath: string, fileBuffer: Buffer): Promise { await fs.promises.writeFile(filePath, fileBuffer); } // Function to update the filename in the database async function updateFilenameInDatabase(answerId: string, filename: string): Promise { await db.update(answers) .set({ filename }) .where(eq(answers.id, answerId)); } const assessmentsRoute = new Hono() .use(authInfo) // Get all aspects .get( "/aspect", // checkPermission("managementAspect.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(10), q: z.string().default(""), }) ), async (c) => { const { includeTrashed, page, limit, q } = c.req.valid("query"); const totalCountQuery = includeTrashed ? sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})` : sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; const aspectIdsQuery = await db .select({ id: aspects.id, }) .from(aspects) .where( and( includeTrashed ? undefined : isNull(aspects.deletedAt), q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined ) ) .offset(page * limit) .limit(limit); const aspectIds = aspectIdsQuery.map(a => a.id); if (aspectIds.length === 0) { return c.json({ data: [], _metadata: { currentPage: page, totalPages: 0, totalItems: 0, perPage: limit, }, }); } // Main query to get aspects, sub-aspects, and number of questions const result = await db .select({ id: aspects.id, name: aspects.name, createdAt: aspects.createdAt, updatedAt: aspects.updatedAt, ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), subAspectId: subAspects.id, subAspectName: subAspects.name, // Increase the number of questions related to sub aspects questionCount: sql`( SELECT count(*) FROM ${questions} WHERE ${questions.subAspectId} = ${subAspects.id} )`.as('questionCount'), fullCount: totalCountQuery, }) .from(aspects) .leftJoin(subAspects, eq(subAspects.aspectId, aspects.id)) .where(inArray(aspects.id, aspectIds)); // Grouping sub aspects by aspect ID const groupedResult = result.reduce((acc, curr) => { const aspectId = curr.id; if (!acc[aspectId]) { acc[aspectId] = { id: curr.id, name: curr.name, createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null, updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null, subAspects: curr.subAspectName ? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }] : [], }; } else { if (curr.subAspectName) { const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId); if (!exists) { acc[aspectId].subAspects.push({ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount, }); } } } return acc; }, {} as Record); const groupedArray = Object.values(groupedResult); return c.json({ data: groupedArray, _metadata: { currentPage: page, totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit), totalItems: Number(result[0]?.fullCount) ?? 0, perPage: limit, }, }); } ) // Get data for current Assessment Score from submitted options By Assessment Id .get( "/getCurrentAssessmentScore", checkPermission("assessments.readAssessmentScore"), requestValidator( "query", z.object({ assessmentId: z.string(), }) ), async (c) => { const { assessmentId } = c.req.valid("query"); // Query to sum the scores of selected options for the current assessment const result = await db .select({ totalScore: sql`SUM(${options.score})`, }) .from(answers) .leftJoin(options, eq(answers.optionId, options.id)) .where(eq(answers.assessmentId, assessmentId)) .execute(); return c.json({ assessmentId, totalScore: result[0]?.totalScore ?? 0, // Return 0 if no answers are found }); } ) // Get all Questions and Options that relate to Sub Aspects and Aspects .get( "/getAllQuestions", checkPermission("assessments.readAllQuestions"), async (c) => { // Definisikan tipe untuk hasil query dan izinkan nilai null type QuestionWithOptions = { aspectsId: string | null; aspectsName: string | null; subAspectId: string | null; subAspectName: string | null; questionId: string | null; questionText: string | null; optionId: string; optionText: string; optionScore: number; fullCount?: number; }; const totalCountQuery = sql`(SELECT count(*) FROM ${options} LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id} LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id} LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id} WHERE ${questions.deletedAt} IS NULL )`; // Sesuaikan tipe hasil query const result: QuestionWithOptions[] = await db .select({ aspectsId: aspects.id, aspectsName: aspects.name, subAspectId: subAspects.id, subAspectName: subAspects.name, questionId: questions.id, questionText: questions.question, optionId: options.id, optionText: options.text, optionScore: options.score, fullCount: totalCountQuery, }) .from(options) .leftJoin(questions, eq(options.questionId, questions.id)) .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) .where(sql`${questions.deletedAt} IS NULL`); // Definisikan tipe untuk hasil pengelompokan type GroupedQuestion = { questionId: string | null; questionText: string | null; aspectsId: string | null; aspectsName: string | null; subAspectId: string | null; subAspectName: string | null; options: { optionId: string; optionText: string; optionScore: number; }[]; }; // Mengelompokkan berdasarkan questionId const groupedResult: GroupedQuestion[] = result.reduce((acc, current) => { const { questionId, questionText, aspectsId, aspectsName, subAspectId, subAspectName, optionId, optionText, optionScore } = current; // Cek apakah questionId sudah ada dalam accumulator const existingQuestion = acc.find(q => q.questionId === questionId); if (existingQuestion) { // Tambahkan opsi baru ke array options dari pertanyaan yang ada existingQuestion.options.push({ optionId, optionText, optionScore }); } else { // Jika pertanyaan belum ada, tambahkan objek baru acc.push({ questionId, questionText, aspectsId, aspectsName, subAspectId, subAspectName, options: [ { optionId, optionText, optionScore } ] }); } return acc; }, [] as GroupedQuestion[]); // Pastikan tipe untuk accumulator didefinisikan return c.json({ data: groupedResult, }); } ) // Get all Answers Data by Assessment Id .get( "/getAnswers", checkPermission("assessments.readAnswers"), requestValidator( "query", z.object({ assessmentId: z.string(), // Require assessmentId as a query parameter 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(1000), q: z.string().default(""), }) ), async (c) => { const { assessmentId, page, limit, q } = c.req.valid("query"); // Query to count total answers for the specific assessmentId const totalCountQuery = sql`(SELECT count(*) FROM ${answers} WHERE ${answers.assessmentId} = ${assessmentId})`; // Query to retrieve answers for the specific assessmentId const result = await db .select({ id: answers.id, assessmentId: answers.assessmentId, optionId: answers.optionId, isFlagged: answers.isFlagged, filename: answers.filename, validationInformation: answers.validationInformation, fullCount: totalCountQuery, }) .from(answers) .where( and( eq(answers.assessmentId, assessmentId), // Filter by assessmentId q ? or( ilike(answers.filename, q), ilike(answers.validationInformation, q), eq(answers.id, q) ) : undefined ) ) .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, }, }); } ) // Toggles the isFlagged field between true and false .patch( "/:questionId/toggleFlag", checkPermission("assessments.toggleFlag"), async (c) => { const questionId = c.req.param("questionId"); // Join answers and options to retrieve answer based on questionId const currentAnswer = await db .select({ isFlagged: answers.isFlagged, answerId: answers.id, }) .from(answers) .innerJoin(options, eq(answers.optionId, options.id)) .where(eq(options.questionId, questionId)) .limit(1); if (!currentAnswer.length) { throw notFound({ message: "Answer not found", }); } // Toggle the isFlagged value const newIsFlaggedValue = !currentAnswer[0].isFlagged; // Update the answer with the toggled value const updatedAnswer = await db .update(answers) .set({ isFlagged: newIsFlaggedValue, }) .where(eq(answers.id, currentAnswer[0].answerId)) .returning(); if (!updatedAnswer.length) { throw notFound({ message: "Failed to update answer", }); } return c.json( { message: "Answer flag toggled successfully", answer: updatedAnswer[0], }, 200 ); } ) // Get data answers from table answers by optionId and assessmentId .post( "/checkDataAnswer", checkPermission("assessments.checkAnswer"), async (c) => { const { optionId, assessmentId } = await c.req.json(); const result = await db .select() .from(answers) .where( and(eq(answers.optionId, optionId), eq(answers.assessmentId, assessmentId)) ) .execute(); const existingAnswer = result[0]; let response; if (existingAnswer) { response = { exists: true, answerId: existingAnswer.id }; } else { response = { exists: false }; } return c.json(response); } ) // Upload filename to the table answers and save the file on the local storage .post( "/uploadFile", checkPermission("assessments.uploadFile"), async (c) => { // Get the Content-Type header const contentType = c.req.header('content-type'); if (!contentType || !contentType.includes('multipart/form-data')) { throw notFound({ message: "Invalid Content-Type", }); } // Extract boundary const boundary = contentType.split('boundary=')[1]; if (!boundary) { throw notFound({ message: "Boundary not found", }); } // Get the raw body const body = await c.req.arrayBuffer(); const bodyString = Buffer.from(body).toString(); // Split the body by the boundary const parts = bodyString.split(`--${boundary}`); let fileUrl = null; for (const part of parts) { if (part.includes('Content-Disposition: form-data;')) { // Extract file name const match = /filename="(.+?)"/.exec(part); if (match) { const fileName = match[1]; const fileContentStart = part.indexOf('\r\n\r\n') + 4; const fileContentEnd = part.lastIndexOf('\r\n'); // Extract file content as Buffer const fileBuffer = Buffer.from(part.slice(fileContentStart, fileContentEnd), 'binary'); // Define file path and save the file const filePath = path.join('images', Date.now() + '-' + fileName); await saveFile(filePath, fileBuffer); // Assuming answerId is passed as a query parameter or in the form-data const answerId = c.req.query('answerId'); if (!answerId) { throw notFound({ message: "answerId is required", }); } await updateFilenameInDatabase(answerId, path.basename(filePath)); // Set the file URL for the final response fileUrl = `/images/${path.basename(filePath)}`; } } } if (!fileUrl) { throw notFound({ message: 'No file uploaded', }); } return c.json( { success: true, imageUrl: fileUrl } ); } ) // Submit option to table answers from use-form in frontend .post( "/submitAnswer", checkPermission("assessments.submitAnswer"), requestValidator("json", answerFormSchema), async (c) => { const answerData = c.req.valid("json"); const answer = await db .insert(answers) .values({ optionId: answerData.optionId, assessmentId: answerData.assessmentId, isFlagged: answerData.isFlagged, filename: answerData.filename, validationInformation: answerData.validationInformation, }) .returning(); return c.json( { message: "Answer created successfully", answer: answer[0], }, 201 ); } ) // Update answer in table answers if answer changes .patch( "/:id/updateAnswer", checkPermission("assessments.updateAnswer"), requestValidator("json", answerUpdateSchema), async (c) => { const answerId = c.req.param("id"); const answerData = c.req.valid("json"); const updatedAnswer = await db .update(answers) .set({ optionId: answerData.optionId, }) .where(eq(answers.id, answerId)) .returning(); if (!updatedAnswer.length) { throw notFound({ message: "Answer not found or update failed" }) } return c.json({ message: "Answer updated successfully", answer: updatedAnswer[0], }); } ) // Get data for One Sub Aspect average score By Sub Aspect Id and Assessment Id .get( '/average-score/sub-aspects/:subAspectId/assessments/:assessmentId', // checkPermission("assessments.readAssessmentScore"), async (c) => { const { subAspectId, assessmentId } = c.req.param(); const averageScore = await db .select({ subAspectName: subAspects.name, average: sql`AVG(options.score)` }) .from(answers) .innerJoin(options, eq(answers.optionId, options.id)) .innerJoin(questions, eq(options.questionId, questions.id)) .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where( sql`sub_aspects.id = ${subAspectId} AND assessments.id = ${assessmentId}` ) .groupBy(subAspects.id); return c.json({ subAspectId, subAspectName: averageScore[0].subAspectName, assessmentId, averageScore: averageScore.length > 0 ? averageScore[0].average : 0 }); } ) // Get data for All Sub Aspects average score By Assessment Id .get( '/average-score/sub-aspects/assessments/:assessmentId', // checkPermission("assessments.readAssessmentScore"), async (c) => { const { assessmentId } = c.req.param(); const averageScores = await db .select({ subAspectId: subAspects.id, subAspectName: subAspects.name, average: sql`AVG(options.score)` }) .from(answers) .innerJoin(options, eq(answers.optionId, options.id)) .innerJoin(questions, eq(options.questionId, questions.id)) .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where(eq(assessments.id, assessmentId)) .groupBy(subAspects.id); return c.json({ assessmentId, subAspects: averageScores.map(score => ({ subAspectId: score.subAspectId, subAspectName: score.subAspectName, averageScore: score.average })) }); } ) // Get data for One Aspect average score By Aspect Id and Assessment Id .get( "/average-score/aspects/:aspectId/assessments/:assessmentId", // checkPermission("assessments.readAverageAspect"), async (c) => { const { aspectId, assessmentId } = c.req.param(); const averageScore = await db .select({ aspectName: aspects.name, average: sql`AVG(options.score)` }) .from(answers) .innerJoin(options, eq(answers.optionId, options.id)) .innerJoin(questions, eq(options.questionId, questions.id)) .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .innerJoin(aspects, eq(subAspects.aspectId, aspects.id)) .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where( sql`aspects.id = ${aspectId} AND assessments.id = ${assessmentId}` ) .groupBy(aspects.id); return c.json({ aspectId, aspectName: averageScore[0].aspectName, assessmentId, averageScore: averageScore.length > 0 ? averageScore[0].average : 0 }); } ) // Get data for All Aspects average score By Assessment Id .get( '/average-score/aspects/assessments/:assessmentId', // checkPermission("assessments.readAssessmentScore"), async (c) => { const { assessmentId } = c.req.param(); const averageScores = await db .select({ AspectId: aspects.id, AspectName: aspects.name, average: sql`AVG(options.score)` }) .from(answers) .innerJoin(options, eq(answers.optionId, options.id)) .innerJoin(questions, eq(options.questionId, questions.id)) .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .innerJoin(aspects, eq(subAspects.aspectId, aspects.id)) .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where(eq(assessments.id, assessmentId)) .groupBy(aspects.id); return c.json({ assessmentId, aspects: averageScores.map(score => ({ AspectId: score.AspectId, AspectName: score.AspectName, averageScore: score.average })) }); } ) export default assessmentsRoute;