diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index 1e91356..9490851 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -32,6 +32,30 @@ const permissionsData = [ { code: "roles.delete", }, + { + code: "assessments.readAssessmentScore", + }, + { + code: "assessments.readAllQuestions", + }, + { + code: "assessments.readAnswers", + }, + { + code: "assessments.toggleFlag", + }, + { + code: "assessments.checkAnswer", + }, + { + code: "assessments.uploadFile", + }, + { + code: "assessments.submitAnswer", + }, + { + code: "assessments.updateAnswer", + }, ] as const; export type SpecificPermissionCode = (typeof permissionsData)[number]["code"]; diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts new file mode 100644 index 0000000..11b7a06 --- /dev/null +++ b/apps/backend/src/routes/assessments/route.ts @@ -0,0 +1,414 @@ +import { and, eq, ilike, 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 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'; + +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, flname: string): Promise { + + await db.update(answers) + .set({ + filename: flname, + }) + .where(eq(answers.id, answerId)); +} + +const assessmentsRoute = new Hono() + .use(authInfo) + + // 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"), + requestValidator( + "query", + z.object({ + 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 { page, limit, q } = c.req.valid("query"); + + 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} + )`; + + const result = await db + .select({ + optionId: options.id, + aspectsId: aspects.id, + aspectsName: aspects.name, + subAspectId: subAspects.id, + subAspectName: subAspects.name, + questionId: questions.id, + questionText: questions.question, + 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( + and( + q + ? or( + ilike(aspects.name, q), + ilike(subAspects.name, q), + ilike(questions.question, q), + ilike(options.text, q), + ilike(options.score, q), + eq(options.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, + }, + }); + } + ) + + // 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( + "/:id/toggleFlag", + checkPermission("assessments.toggleFlag"), + async (c) => { + const answerId = c.req.param("id"); + + // Retrieve the current state of isFlagged + const currentAnswer = await db + .select({ + isFlagged: answers.isFlagged, + }) + .from(answers) + .where(eq(answers.id, answerId)) + .limit(1); + + if (!currentAnswer.length) { + return c.json( + { + message: "Answer not found", + }, + 404 + ); + } + + // 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, answerId)) + .returning(); + + if (!updatedAnswer.length) { + return c.json( + { + message: "Failed to update answer", + }, + 500 + ); + } + + 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]; + + if (existingAnswer) { + return c.json({ exists: true, answerId: existingAnswer.id }); + } else { + return c.json({ exists: false }); + } + } + ) + + // 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')) { + return c.json({ success: false, message: 'Invalid Content-Type' }); + } + + // Extract boundary + const boundary = contentType.split('boundary=')[1]; + if (!boundary) { + return c.json({ success: false, 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}`); + + 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) { + await updateFilenameInDatabase(answerId, path.basename(filePath)); + } + + // Return the file URL + const fileUrl = `/images/${path.basename(filePath)}`; + return c.json({ success: true, imageUrl: fileUrl }); + } + } + } + + return c.json({ success: false, message: 'No file uploaded' }); + } + ) + + // 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) { + return c.json( + { + message: "Answer not found or update failed", + }, + 404 + ); + } + + return c.json( + { + message: "Answer updated successfully", + answer: updatedAnswer[0], + }, + 200 + ); + } + ) + +export default assessmentsRoute;