From 0f0f974c5ee3679745699fbffeb9f24762f6093b Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Wed, 9 Oct 2024 11:45:57 +0700 Subject: [PATCH] update: revision backend for assessment --- apps/backend/src/routes/assessments/route.ts | 370 ++++++++++++++----- 1 file changed, 278 insertions(+), 92 deletions(-) diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 462fd09..4b00eee 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -1,4 +1,4 @@ -import { and, eq, ilike, or, sql } from "drizzle-orm"; +import { and, eq, ilike, isNull, inArray, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import db from "../../drizzle"; @@ -42,6 +42,133 @@ async function updateFilenameInDatabase(answerId: string, filename: string): Pro 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", @@ -54,7 +181,7 @@ const assessmentsRoute = new Hono() ), 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({ @@ -64,20 +191,34 @@ const assessmentsRoute = new Hono() .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) => { - const totalCountQuery = + // 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} @@ -86,15 +227,16 @@ const assessmentsRoute = new Hono() WHERE ${questions.deletedAt} IS NULL )`; - const result = await db + // Sesuaikan tipe hasil query + const result: QuestionWithOptions[] = await db .select({ - optionId: options.id, 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, @@ -103,15 +245,61 @@ const assessmentsRoute = new Hono() .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`) + .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: result.map((d) => ( - { - ...d, - fullCount: undefined - } - )), + data: groupedResult, }); } ) @@ -135,13 +323,13 @@ const assessmentsRoute = new Hono() ), async (c) => { const { assessmentId, page, limit, q } = c.req.valid("query"); - + // Query to count total answers for the specific assessmentId - const totalCountQuery = + const totalCountQuery = sql`(SELECT count(*) FROM ${answers} WHERE ${answers.assessmentId} = ${assessmentId})`; - + // Query to retrieve answers for the specific assessmentId const result = await db .select({ @@ -159,16 +347,16 @@ const assessmentsRoute = new Hono() eq(answers.assessmentId, assessmentId), // Filter by assessmentId q ? or( - ilike(answers.filename, q), - ilike(answers.validationInformation, q), - eq(answers.id, q) - ) + 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: { @@ -185,48 +373,46 @@ const assessmentsRoute = new Hono() // Toggles the isFlagged field between true and false .patch( - "/:id/toggleFlag", + "/:questionId/toggleFlag", checkPermission("assessments.toggleFlag"), async (c) => { - const answerId = c.req.param("id"); - - // Retrieve the current state of isFlagged + 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) - .where(eq(answers.id, answerId)) + .innerJoin(options, eq(answers.optionId, options.id)) + .where(eq(options.questionId, questionId)) .limit(1); - + if (!currentAnswer.length) { - throw notFound( - { - message: "Answer not found", - } - ) + 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, answerId)) + .where(eq(answers.id, currentAnswer[0].answerId)) .returning(); - + if (!updatedAnswer.length) { - throw notFound( - { - message: "Failed to update answer", - } - ) + throw notFound({ + message: "Failed to update answer", + }); } - + return c.json( { message: "Answer flag toggled successfully", @@ -235,7 +421,7 @@ const assessmentsRoute = new Hono() 200 ); } - ) + ) // Get data answers from table answers by optionId and assessmentId .post( @@ -243,7 +429,7 @@ const assessmentsRoute = new Hono() checkPermission("assessments.checkAnswer"), async (c) => { const { optionId, assessmentId } = await c.req.json(); - + const result = await db .select() .from(answers) @@ -251,24 +437,24 @@ const assessmentsRoute = new Hono() and(eq(answers.optionId, optionId), eq(answers.assessmentId, assessmentId)) ) .execute(); - + const existingAnswer = result[0]; let response; - + if (existingAnswer) { - response = { - exists: true, - answerId: existingAnswer.id + response = { + exists: true, + answerId: existingAnswer.id }; } else { - response = { - exists: false + response = { + exists: false }; } - + return c.json(response); } - ) + ) // Upload filename to the table answers and save the file on the local storage .post( @@ -282,7 +468,7 @@ const assessmentsRoute = new Hono() message: "Invalid Content-Type", }); } - + // Extract boundary const boundary = contentType.split('boundary=')[1]; if (!boundary) { @@ -290,16 +476,16 @@ const assessmentsRoute = new Hono() 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 @@ -308,14 +494,14 @@ const assessmentsRoute = new Hono() 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) { @@ -323,29 +509,29 @@ const assessmentsRoute = new Hono() 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 + { + success: true, + imageUrl: fileUrl } ); } - ) + ) // Submit option to table answers from use-form in frontend .post( @@ -384,7 +570,7 @@ const assessmentsRoute = new Hono() async (c) => { const answerId = c.req.param("id"); const answerData = c.req.valid("json"); - + const updatedAnswer = await db .update(answers) .set({ @@ -392,27 +578,27 @@ const assessmentsRoute = new Hono() }) .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], + 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.readAverageSubAspect"), + '/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, @@ -427,7 +613,7 @@ const assessmentsRoute = new Hono() sql`sub_aspects.id = ${subAspectId} AND assessments.id = ${assessmentId}` ) .groupBy(subAspects.id); - + return c.json({ subAspectId, subAspectName: averageScore[0].subAspectName, @@ -437,13 +623,13 @@ const assessmentsRoute = new Hono() } ) - // Get data for All Sub Aspects average score By Assessment Id + // Get data for All Sub Aspects average score By Assessment Id .get( - '/average-score/sub-aspects/assessments/:assessmentId', - checkPermission("assessments.readAverageAllSubAspects"), + '/average-score/sub-aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), async (c) => { const { assessmentId } = c.req.param(); - + const averageScores = await db .select({ subAspectId: subAspects.id, @@ -457,7 +643,7 @@ const assessmentsRoute = new Hono() .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where(eq(assessments.id, assessmentId)) .groupBy(subAspects.id); - + return c.json({ assessmentId, subAspects: averageScores.map(score => ({ @@ -471,11 +657,11 @@ const assessmentsRoute = new Hono() // Get data for One Aspect average score By Aspect Id and Assessment Id .get( - "/average-score/aspects/:aspectId/assessments/:assessmentId", - checkPermission("assessments.readAverageAspect"), + "/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, @@ -491,7 +677,7 @@ const assessmentsRoute = new Hono() sql`aspects.id = ${aspectId} AND assessments.id = ${assessmentId}` ) .groupBy(aspects.id); - + return c.json({ aspectId, aspectName: averageScore[0].aspectName, @@ -503,11 +689,11 @@ const assessmentsRoute = new Hono() // Get data for All Aspects average score By Assessment Id .get( - '/average-score/aspects/assessments/:assessmentId', - checkPermission("assessments.readAverageAllAspects"), + '/average-score/aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), async (c) => { const { assessmentId } = c.req.param(); - + const averageScores = await db .select({ AspectId: aspects.id, @@ -522,7 +708,7 @@ const assessmentsRoute = new Hono() .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where(eq(assessments.id, assessmentId)) .groupBy(aspects.id); - + return c.json({ assessmentId, aspects: averageScores.map(score => ({