From b30de1b6e3edc2ad8ef8dcadbed9048e792bd8f4 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Thu, 15 Aug 2024 15:24:17 +0700 Subject: [PATCH 01/15] create: API for Assessments --- apps/backend/src/data/permissions.ts | 24 ++ apps/backend/src/routes/assessments/route.ts | 414 +++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 apps/backend/src/routes/assessments/route.ts 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; From 82ba52d5b452cdae7d03857ded7adbcbdeabe566 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Thu, 15 Aug 2024 15:29:02 +0700 Subject: [PATCH 02/15] create: route for Assessments --- apps/backend/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index c0fbe56..380afe3 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -15,6 +15,7 @@ import DashboardError from "./errors/DashboardError"; import HonoEnv from "./types/HonoEnv"; import devRoutes from "./routes/dev/route"; import appEnv from "./appEnv"; +import assessmentsRoute from "./routes/assessments/route"; configDotenv(); @@ -78,6 +79,7 @@ const routes = app .route("/dashboard", dashboardRoutes) .route("/roles", rolesRoute) .route("/dev", devRoutes) + .route("/assessments", assessmentsRoute) .onError((err, c) => { if (err instanceof DashboardError) { return c.json( From 26b5b9228a84d4f01b5437a161d80d9742c084ce Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 16 Aug 2024 13:38:20 +0700 Subject: [PATCH 03/15] create: fetch data in assessmentsQueries to FE still error --- .../queries/assessmentsQueries.ts | 72 ++++++++++ .../assessments/index.lazy.tsx | 131 ++++++++++++++++++ .../_dashboardLayout/assessments/index.tsx | 5 + 3 files changed, 208 insertions(+) create mode 100644 apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx diff --git a/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts b/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts new file mode 100644 index 0000000..c15dd34 --- /dev/null +++ b/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts @@ -0,0 +1,72 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; +import { InferRequestType } from "hono"; + +export const questionsQueryOptions = (page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["questions", { page, limit, q }], + queryFn: async () => { + const result = await fetchRPC( + client.assessments.getAllQuestions.$get({ + query: { + limit: String(limit), + page: String(page), + q, + }, + }) + ); + console.log('Result from fetchRPC:', result); + return result; + }, + }); + + +export const getUserByIdQueryOptions = (userId: string | undefined) => + queryOptions({ + queryKey: ["user", userId], + queryFn: () => + fetchRPC( + client.users[":id"].$get({ + param: { + id: userId!, + }, + query: {}, + }) + ), + enabled: Boolean(userId), + }); + +export const createUser = async ( + form: InferRequestType["form"] +) => { + return await fetchRPC( + client.users.$post({ + form, + }) + ); +}; + +export const updateUser = async ( + form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & { + id: string; + } +) => { + return await fetchRPC( + client.users[":id"].$patch({ + param: { + id: form.id, + }, + form, + }) + ); +}; + +export const deleteUser = async (id: string) => { + return await fetchRPC( + client.users[":id"].$delete({ + param: { id }, + form: {}, + }) + ); +}; diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx new file mode 100644 index 0000000..15175be --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx @@ -0,0 +1,131 @@ +import { createLazyFileRoute } from '@tanstack/react-router' + +import { Button } from "@/shadcn/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/shadcn/components/ui/card" +import { Label } from "@/shadcn/components/ui/label" +import { RadioGroup, RadioGroupItem } from "@/shadcn/components/ui/radio-group" +// import ExtractQueryDataType from '@/types/ExtractQueryDataType' +// import { createColumnHelper } from '@tanstack/react-table' +import { questionsQueryOptions } from "@/modules/assessmentsManagement/queries/assessmentsQueries"; +// import { useForm } from '@mantine/form' +import { useQuery } from '@tanstack/react-query' + +// type DataType = ExtractQueryDataType; +// const columnHelper = useForm(); + +export function CardWithAssessment() { + // const { handleSubmit, form } = useForm(); + + const { page = 1, limit = 10, q = '' } = {} + + const { data, isLoading, isError } = useQuery(questionsQueryOptions(page, limit, q)); + + console.log(data) + + if (isLoading) return
Loading...
; + if (isError) return
Error fetching data
; + + // const groupedQuestions = data?.reduce((acc, item) => { + // const { subAspectId, subAspectName } = item; + // if (!acc[subAspectId]) { + // acc[subAspectId] = { + // subAspectName, + // questions: [], + // }; + // } + // acc[subAspectId].questions.push(item); + // return acc; + // }, {} as Record); + + + const renderQuestions = () => { + return data?.data.map((subAspect) => ( +
+

{subAspect.subAspectName}

+ {data?.data.map((question) => ( +
+ + + {data?.data.map((option) => ( +
+ + +
+ ))} +
+
+ ))} +
+ )); + }; + + const renderQuestionNumbers = () => { + const questionNumbers: number[] = []; + data?.data.forEach((subAspect) => { + data?.data.forEach((question, index) => { + questionNumbers.push(index + 1); + }); + }); + + return questionNumbers.map((num) => ( + + )); + }; + + return ( + +
+ + Pertanyaan + + Bacalah pertanyaan dengan hati-hati dan jawablah dengan teliti! + + + +
+
+ {renderQuestions()} +
+
+
+ + + + +
+
+ + Daftar Nomor + + +
+
+ {renderQuestionNumbers()} +
+
+
+
+
+ ); +} + +export const Route = createLazyFileRoute('/_dashboardLayout/assessments/')({ + component: () => +
+

+ Halaman Assessments +

+ +
+}) \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx new file mode 100644 index 0000000..57a1b20 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_dashboardLayout/assessments/')({ + component: () =>
Hello /_dashboardLayout/assessments/!
+}) \ No newline at end of file From 51d249f4c6f466ee42ef95ade704c597deabb849 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 16 Aug 2024 14:10:02 +0700 Subject: [PATCH 04/15] Revert "create: fetch data in assessmentsQueries to FE still error" This reverts commit 26b5b9228a84d4f01b5437a161d80d9742c084ce. --- .../queries/assessmentsQueries.ts | 72 ---------- .../assessments/index.lazy.tsx | 131 ------------------ .../_dashboardLayout/assessments/index.tsx | 5 - 3 files changed, 208 deletions(-) delete mode 100644 apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts delete mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx delete mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx diff --git a/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts b/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts deleted file mode 100644 index c15dd34..0000000 --- a/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts +++ /dev/null @@ -1,72 +0,0 @@ -import client from "@/honoClient"; -import fetchRPC from "@/utils/fetchRPC"; -import { queryOptions } from "@tanstack/react-query"; -import { InferRequestType } from "hono"; - -export const questionsQueryOptions = (page: number, limit: number, q?: string) => - queryOptions({ - queryKey: ["questions", { page, limit, q }], - queryFn: async () => { - const result = await fetchRPC( - client.assessments.getAllQuestions.$get({ - query: { - limit: String(limit), - page: String(page), - q, - }, - }) - ); - console.log('Result from fetchRPC:', result); - return result; - }, - }); - - -export const getUserByIdQueryOptions = (userId: string | undefined) => - queryOptions({ - queryKey: ["user", userId], - queryFn: () => - fetchRPC( - client.users[":id"].$get({ - param: { - id: userId!, - }, - query: {}, - }) - ), - enabled: Boolean(userId), - }); - -export const createUser = async ( - form: InferRequestType["form"] -) => { - return await fetchRPC( - client.users.$post({ - form, - }) - ); -}; - -export const updateUser = async ( - form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & { - id: string; - } -) => { - return await fetchRPC( - client.users[":id"].$patch({ - param: { - id: form.id, - }, - form, - }) - ); -}; - -export const deleteUser = async (id: string) => { - return await fetchRPC( - client.users[":id"].$delete({ - param: { id }, - form: {}, - }) - ); -}; diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx deleted file mode 100644 index 15175be..0000000 --- a/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { createLazyFileRoute } from '@tanstack/react-router' - -import { Button } from "@/shadcn/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/shadcn/components/ui/card" -import { Label } from "@/shadcn/components/ui/label" -import { RadioGroup, RadioGroupItem } from "@/shadcn/components/ui/radio-group" -// import ExtractQueryDataType from '@/types/ExtractQueryDataType' -// import { createColumnHelper } from '@tanstack/react-table' -import { questionsQueryOptions } from "@/modules/assessmentsManagement/queries/assessmentsQueries"; -// import { useForm } from '@mantine/form' -import { useQuery } from '@tanstack/react-query' - -// type DataType = ExtractQueryDataType; -// const columnHelper = useForm(); - -export function CardWithAssessment() { - // const { handleSubmit, form } = useForm(); - - const { page = 1, limit = 10, q = '' } = {} - - const { data, isLoading, isError } = useQuery(questionsQueryOptions(page, limit, q)); - - console.log(data) - - if (isLoading) return
Loading...
; - if (isError) return
Error fetching data
; - - // const groupedQuestions = data?.reduce((acc, item) => { - // const { subAspectId, subAspectName } = item; - // if (!acc[subAspectId]) { - // acc[subAspectId] = { - // subAspectName, - // questions: [], - // }; - // } - // acc[subAspectId].questions.push(item); - // return acc; - // }, {} as Record); - - - const renderQuestions = () => { - return data?.data.map((subAspect) => ( -
-

{subAspect.subAspectName}

- {data?.data.map((question) => ( -
- - - {data?.data.map((option) => ( -
- - -
- ))} -
-
- ))} -
- )); - }; - - const renderQuestionNumbers = () => { - const questionNumbers: number[] = []; - data?.data.forEach((subAspect) => { - data?.data.forEach((question, index) => { - questionNumbers.push(index + 1); - }); - }); - - return questionNumbers.map((num) => ( - - )); - }; - - return ( - -
- - Pertanyaan - - Bacalah pertanyaan dengan hati-hati dan jawablah dengan teliti! - - - -
-
- {renderQuestions()} -
-
-
- - - - -
-
- - Daftar Nomor - - -
-
- {renderQuestionNumbers()} -
-
-
-
-
- ); -} - -export const Route = createLazyFileRoute('/_dashboardLayout/assessments/')({ - component: () => -
-

- Halaman Assessments -

- -
-}) \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx deleted file mode 100644 index 57a1b20..0000000 --- a/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/_dashboardLayout/assessments/')({ - component: () =>
Hello /_dashboardLayout/assessments/!
-}) \ No newline at end of file From 208a1a1fffa1f415570fe6b437fe3e4a983039be Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 16 Aug 2024 14:10:33 +0700 Subject: [PATCH 05/15] Reapply "create: fetch data in assessmentsQueries to FE still error" This reverts commit 51d249f4c6f466ee42ef95ade704c597deabb849. --- .../queries/assessmentsQueries.ts | 72 ++++++++++ .../assessments/index.lazy.tsx | 131 ++++++++++++++++++ .../_dashboardLayout/assessments/index.tsx | 5 + 3 files changed, 208 insertions(+) create mode 100644 apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx diff --git a/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts b/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts new file mode 100644 index 0000000..c15dd34 --- /dev/null +++ b/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts @@ -0,0 +1,72 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; +import { InferRequestType } from "hono"; + +export const questionsQueryOptions = (page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["questions", { page, limit, q }], + queryFn: async () => { + const result = await fetchRPC( + client.assessments.getAllQuestions.$get({ + query: { + limit: String(limit), + page: String(page), + q, + }, + }) + ); + console.log('Result from fetchRPC:', result); + return result; + }, + }); + + +export const getUserByIdQueryOptions = (userId: string | undefined) => + queryOptions({ + queryKey: ["user", userId], + queryFn: () => + fetchRPC( + client.users[":id"].$get({ + param: { + id: userId!, + }, + query: {}, + }) + ), + enabled: Boolean(userId), + }); + +export const createUser = async ( + form: InferRequestType["form"] +) => { + return await fetchRPC( + client.users.$post({ + form, + }) + ); +}; + +export const updateUser = async ( + form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & { + id: string; + } +) => { + return await fetchRPC( + client.users[":id"].$patch({ + param: { + id: form.id, + }, + form, + }) + ); +}; + +export const deleteUser = async (id: string) => { + return await fetchRPC( + client.users[":id"].$delete({ + param: { id }, + form: {}, + }) + ); +}; diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx new file mode 100644 index 0000000..15175be --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx @@ -0,0 +1,131 @@ +import { createLazyFileRoute } from '@tanstack/react-router' + +import { Button } from "@/shadcn/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/shadcn/components/ui/card" +import { Label } from "@/shadcn/components/ui/label" +import { RadioGroup, RadioGroupItem } from "@/shadcn/components/ui/radio-group" +// import ExtractQueryDataType from '@/types/ExtractQueryDataType' +// import { createColumnHelper } from '@tanstack/react-table' +import { questionsQueryOptions } from "@/modules/assessmentsManagement/queries/assessmentsQueries"; +// import { useForm } from '@mantine/form' +import { useQuery } from '@tanstack/react-query' + +// type DataType = ExtractQueryDataType; +// const columnHelper = useForm(); + +export function CardWithAssessment() { + // const { handleSubmit, form } = useForm(); + + const { page = 1, limit = 10, q = '' } = {} + + const { data, isLoading, isError } = useQuery(questionsQueryOptions(page, limit, q)); + + console.log(data) + + if (isLoading) return
Loading...
; + if (isError) return
Error fetching data
; + + // const groupedQuestions = data?.reduce((acc, item) => { + // const { subAspectId, subAspectName } = item; + // if (!acc[subAspectId]) { + // acc[subAspectId] = { + // subAspectName, + // questions: [], + // }; + // } + // acc[subAspectId].questions.push(item); + // return acc; + // }, {} as Record); + + + const renderQuestions = () => { + return data?.data.map((subAspect) => ( +
+

{subAspect.subAspectName}

+ {data?.data.map((question) => ( +
+ + + {data?.data.map((option) => ( +
+ + +
+ ))} +
+
+ ))} +
+ )); + }; + + const renderQuestionNumbers = () => { + const questionNumbers: number[] = []; + data?.data.forEach((subAspect) => { + data?.data.forEach((question, index) => { + questionNumbers.push(index + 1); + }); + }); + + return questionNumbers.map((num) => ( + + )); + }; + + return ( + +
+ + Pertanyaan + + Bacalah pertanyaan dengan hati-hati dan jawablah dengan teliti! + + + +
+
+ {renderQuestions()} +
+
+
+ + + + +
+
+ + Daftar Nomor + + +
+
+ {renderQuestionNumbers()} +
+
+
+
+
+ ); +} + +export const Route = createLazyFileRoute('/_dashboardLayout/assessments/')({ + component: () => +
+

+ Halaman Assessments +

+ +
+}) \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx new file mode 100644 index 0000000..57a1b20 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_dashboardLayout/assessments/')({ + component: () =>
Hello /_dashboardLayout/assessments/!
+}) \ No newline at end of file From 1b545abf5fdb585933305d3462e8ee6b64498c80 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 16 Aug 2024 14:14:27 +0700 Subject: [PATCH 06/15] Revert "Reapply "create: fetch data in assessmentsQueries to FE still error"" This reverts commit 208a1a1fffa1f415570fe6b437fe3e4a983039be. --- .../queries/assessmentsQueries.ts | 72 ---------- .../assessments/index.lazy.tsx | 131 ------------------ .../_dashboardLayout/assessments/index.tsx | 5 - 3 files changed, 208 deletions(-) delete mode 100644 apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts delete mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx delete mode 100644 apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx diff --git a/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts b/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts deleted file mode 100644 index c15dd34..0000000 --- a/apps/frontend/src/modules/assessmentsManagement/queries/assessmentsQueries.ts +++ /dev/null @@ -1,72 +0,0 @@ -import client from "@/honoClient"; -import fetchRPC from "@/utils/fetchRPC"; -import { queryOptions } from "@tanstack/react-query"; -import { InferRequestType } from "hono"; - -export const questionsQueryOptions = (page: number, limit: number, q?: string) => - queryOptions({ - queryKey: ["questions", { page, limit, q }], - queryFn: async () => { - const result = await fetchRPC( - client.assessments.getAllQuestions.$get({ - query: { - limit: String(limit), - page: String(page), - q, - }, - }) - ); - console.log('Result from fetchRPC:', result); - return result; - }, - }); - - -export const getUserByIdQueryOptions = (userId: string | undefined) => - queryOptions({ - queryKey: ["user", userId], - queryFn: () => - fetchRPC( - client.users[":id"].$get({ - param: { - id: userId!, - }, - query: {}, - }) - ), - enabled: Boolean(userId), - }); - -export const createUser = async ( - form: InferRequestType["form"] -) => { - return await fetchRPC( - client.users.$post({ - form, - }) - ); -}; - -export const updateUser = async ( - form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & { - id: string; - } -) => { - return await fetchRPC( - client.users[":id"].$patch({ - param: { - id: form.id, - }, - form, - }) - ); -}; - -export const deleteUser = async (id: string) => { - return await fetchRPC( - client.users[":id"].$delete({ - param: { id }, - form: {}, - }) - ); -}; diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx deleted file mode 100644 index 15175be..0000000 --- a/apps/frontend/src/routes/_dashboardLayout/assessments/index.lazy.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { createLazyFileRoute } from '@tanstack/react-router' - -import { Button } from "@/shadcn/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/shadcn/components/ui/card" -import { Label } from "@/shadcn/components/ui/label" -import { RadioGroup, RadioGroupItem } from "@/shadcn/components/ui/radio-group" -// import ExtractQueryDataType from '@/types/ExtractQueryDataType' -// import { createColumnHelper } from '@tanstack/react-table' -import { questionsQueryOptions } from "@/modules/assessmentsManagement/queries/assessmentsQueries"; -// import { useForm } from '@mantine/form' -import { useQuery } from '@tanstack/react-query' - -// type DataType = ExtractQueryDataType; -// const columnHelper = useForm(); - -export function CardWithAssessment() { - // const { handleSubmit, form } = useForm(); - - const { page = 1, limit = 10, q = '' } = {} - - const { data, isLoading, isError } = useQuery(questionsQueryOptions(page, limit, q)); - - console.log(data) - - if (isLoading) return
Loading...
; - if (isError) return
Error fetching data
; - - // const groupedQuestions = data?.reduce((acc, item) => { - // const { subAspectId, subAspectName } = item; - // if (!acc[subAspectId]) { - // acc[subAspectId] = { - // subAspectName, - // questions: [], - // }; - // } - // acc[subAspectId].questions.push(item); - // return acc; - // }, {} as Record); - - - const renderQuestions = () => { - return data?.data.map((subAspect) => ( -
-

{subAspect.subAspectName}

- {data?.data.map((question) => ( -
- - - {data?.data.map((option) => ( -
- - -
- ))} -
-
- ))} -
- )); - }; - - const renderQuestionNumbers = () => { - const questionNumbers: number[] = []; - data?.data.forEach((subAspect) => { - data?.data.forEach((question, index) => { - questionNumbers.push(index + 1); - }); - }); - - return questionNumbers.map((num) => ( - - )); - }; - - return ( - -
- - Pertanyaan - - Bacalah pertanyaan dengan hati-hati dan jawablah dengan teliti! - - - -
-
- {renderQuestions()} -
-
-
- - - - -
-
- - Daftar Nomor - - -
-
- {renderQuestionNumbers()} -
-
-
-
-
- ); -} - -export const Route = createLazyFileRoute('/_dashboardLayout/assessments/')({ - component: () => -
-

- Halaman Assessments -

- -
-}) \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx deleted file mode 100644 index 57a1b20..0000000 --- a/apps/frontend/src/routes/_dashboardLayout/assessments/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/_dashboardLayout/assessments/')({ - component: () =>
Hello /_dashboardLayout/assessments/!
-}) \ No newline at end of file From 7a9980ec73b89b98bfead1c200e8343859b1881c Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 16 Aug 2024 14:18:57 +0700 Subject: [PATCH 07/15] update: route in assessments --- apps/backend/src/routes/assessments/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 11b7a06..95333c0 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -412,3 +412,4 @@ const assessmentsRoute = new Hono() ) export default assessmentsRoute; + From ab37456906115457e450f49589f29341bd057cbe Mon Sep 17 00:00:00 2001 From: falendikategar Date: Tue, 20 Aug 2024 12:24:28 +0700 Subject: [PATCH 08/15] fix: revise route for Assessments --- apps/backend/src/routes/assessments/route.ts | 170 ++++++++++--------- 1 file changed, 87 insertions(+), 83 deletions(-) diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 95333c0..fea0cb6 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -13,6 +13,7 @@ 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), @@ -30,12 +31,10 @@ async function saveFile(filePath: string, fileBuffer: Buffer): Promise { } // Function to update the filename in the database -async function updateFilenameInDatabase(answerId: string, flname: string): Promise { +async function updateFilenameInDatabase(answerId: string, filename: string): Promise { await db.update(answers) - .set({ - filename: flname, - }) + .set({ filename }) .where(eq(answers.id, answerId)); } @@ -76,23 +75,14 @@ const assessmentsRoute = new Hono() .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} + WHERE ${questions.deletedAt} IS NULL )`; const result = await db @@ -112,34 +102,15 @@ 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( - 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); + .where(sql`${questions.deletedAt} IS NULL`) 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, - }, + data: result.map((d) => ( + { + ...d, + fullCount: undefined + } + )), }); } ) @@ -165,7 +136,10 @@ const assessmentsRoute = new Hono() 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})`; + const totalCountQuery = + sql`(SELECT count(*) + FROM ${answers} + WHERE ${answers.assessmentId} = ${assessmentId})`; // Query to retrieve answers for the specific assessmentId const result = await db @@ -225,12 +199,11 @@ const assessmentsRoute = new Hono() .limit(1); if (!currentAnswer.length) { - return c.json( + throw notFound( { message: "Answer not found", - }, - 404 - ); + } + ) } // Toggle the isFlagged value @@ -246,12 +219,11 @@ const assessmentsRoute = new Hono() .returning(); if (!updatedAnswer.length) { - return c.json( + throw notFound( { message: "Failed to update answer", - }, - 500 - ); + } + ) } return c.json( @@ -270,7 +242,7 @@ const assessmentsRoute = new Hono() checkPermission("assessments.checkAnswer"), async (c) => { const { optionId, assessmentId } = await c.req.json(); - + const result = await db .select() .from(answers) @@ -278,16 +250,24 @@ const assessmentsRoute = new Hono() and(eq(answers.optionId, optionId), eq(answers.assessmentId, assessmentId)) ) .execute(); - + const existingAnswer = result[0]; - + let response; + if (existingAnswer) { - return c.json({ exists: true, answerId: existingAnswer.id }); + response = { + exists: true, + answerId: existingAnswer.id + }; } else { - return c.json({ exists: false }); + response = { + exists: false + }; } + + return c.json(response); } - ) + ) // Upload filename to the table answers and save the file on the local storage .post( @@ -297,22 +277,28 @@ const assessmentsRoute = new Hono() // 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' }); + throw notFound({ + message: "Invalid Content-Type", + }); } // Extract boundary const boundary = contentType.split('boundary=')[1]; if (!boundary) { - return c.json({ success: false, message: 'Boundary not found' }); + 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 @@ -321,30 +307,44 @@ 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) { - await updateFilenameInDatabase(answerId, path.basename(filePath)); + if (!answerId) { + throw notFound({ + message: "answerId is required", + }); } - - // Return the file URL - const fileUrl = `/images/${path.basename(filePath)}`; - return c.json({ success: true, imageUrl: fileUrl }); + + await updateFilenameInDatabase(answerId, path.basename(filePath)); + + // Set the file URL for the final response + fileUrl = `/images/${path.basename(filePath)}`; } } } - return c.json({ success: false, message: 'No file uploaded' }); + 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( @@ -384,6 +384,8 @@ const assessmentsRoute = new Hono() const answerId = c.req.param("id"); const answerData = c.req.valid("json"); + let response; + let statusCode; const updatedAnswer = await db .update(answers) .set({ @@ -393,23 +395,25 @@ const assessmentsRoute = new Hono() .returning(); if (!updatedAnswer.length) { - return c.json( - { - message: "Answer not found or update failed", - }, - 404 - ); + response = { + message: "Answer not found or update failed", + }; + statusCode = 404; + } else { + response = { + message: "Answer updated successfully", + answer: updatedAnswer[0], + }; + statusCode = 200; } return c.json( - { - message: "Answer updated successfully", - answer: updatedAnswer[0], - }, - 200 + response, + { + status: statusCode + } ); } - ) + ) export default assessmentsRoute; - From c0e28b1019635b302c653b7f395daa5e93a8cad1 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Tue, 20 Aug 2024 17:25:58 +0700 Subject: [PATCH 09/15] fix: revise dashboardError for updateAnswer on Assessments --- apps/backend/src/routes/assessments/route.ts | 29 ++++++-------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index fea0cb6..02781f5 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -383,9 +383,7 @@ const assessmentsRoute = new Hono() async (c) => { const answerId = c.req.param("id"); const answerData = c.req.valid("json"); - - let response; - let statusCode; + const updatedAnswer = await db .update(answers) .set({ @@ -395,24 +393,15 @@ const assessmentsRoute = new Hono() .returning(); if (!updatedAnswer.length) { - response = { - message: "Answer not found or update failed", - }; - statusCode = 404; - } else { - response = { - message: "Answer updated successfully", - answer: updatedAnswer[0], - }; - statusCode = 200; + throw notFound({ + message: "Answer not found or update failed" + }) } - - return c.json( - response, - { - status: statusCode - } - ); + + return c.json({ + message: "Answer updated successfully", + answer: updatedAnswer[0], + }); } ) From 68eb7468e99811396221c118acaea680668ce575 Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Thu, 22 Aug 2024 15:00:05 +0700 Subject: [PATCH 10/15] update: status and validatedBy column --- apps/backend/src/drizzle/schema/assessments.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/drizzle/schema/assessments.ts b/apps/backend/src/drizzle/schema/assessments.ts index 44eb4ed..14bbc12 100644 --- a/apps/backend/src/drizzle/schema/assessments.ts +++ b/apps/backend/src/drizzle/schema/assessments.ts @@ -4,7 +4,7 @@ import { relations } from "drizzle-orm"; import { respondents } from "./respondents"; import { users } from "./users"; -const statusEnum = pgEnum("status", ["tertunda", "disetujui", "ditolak", "selesai"]); +const statusEnum = pgEnum("status", ["menunggu konfirmasi", "disetujui", "ditolak", "selesai"]); export const assessments = pgTable("assessments", { id: varchar("id", { length: 50 }) @@ -14,10 +14,9 @@ export const assessments = pgTable("assessments", { status: statusEnum("status"), reviewedBy: varchar("reviewedBy"), reviewedAt: timestamp("reviewedAt", { mode: "date" }), - validatedBy: varchar("validatedBy").notNull(), + validatedBy: varchar("validatedBy"), validatedAt: timestamp("validatedAt", { mode: "date" }), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), - }); // Query Tools in PosgreSQL // CREATE TYPE status AS ENUM ('tertunda', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file From d22e4c01612d761fa044566fed8236f8f360a42d Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Thu, 22 Aug 2024 15:23:59 +0700 Subject: [PATCH 11/15] update: Query Tools for status --- apps/backend/src/drizzle/schema/assessments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/drizzle/schema/assessments.ts b/apps/backend/src/drizzle/schema/assessments.ts index 14bbc12..75b9ccd 100644 --- a/apps/backend/src/drizzle/schema/assessments.ts +++ b/apps/backend/src/drizzle/schema/assessments.ts @@ -19,4 +19,4 @@ export const assessments = pgTable("assessments", { createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), }); // Query Tools in PosgreSQL -// CREATE TYPE status AS ENUM ('tertunda', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file +// CREATE TYPE status AS ENUM ('menunggu konfirmasi', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file From d0808a54c4e9a56b06f26e5451adcd10757ed6a7 Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Thu, 22 Aug 2024 15:54:34 +0700 Subject: [PATCH 12/15] feat: develop API forgot password --- apps/backend/package.json | 2 + apps/backend/src/appEnv.ts | 1 + apps/backend/src/drizzle/schema/users.ts | 1 + apps/backend/src/index.ts | 2 + .../src/routes/forgotPassword/route.ts | 111 ++++++++++++++++++ apps/backend/src/utils/authUtils.ts | 38 ++++++ apps/backend/src/utils/mailerUtils.ts | 32 +++++ pnpm-lock.yaml | 28 ++++- 8 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/routes/forgotPassword/route.ts create mode 100644 apps/backend/src/utils/mailerUtils.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index a923b14..21a8d3b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,6 +19,7 @@ "hono": "^4.4.6", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", + "nodemailer": "^6.9.14", "postgres": "^3.4.4", "sharp": "^0.33.4", "zod": "^3.23.8" @@ -27,6 +28,7 @@ "@types/bcrypt": "^5.0.2", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.2", + "@types/nodemailer": "^6.4.15", "drizzle-kit": "^0.22.7", "pg": "^8.12.0", "tsx": "^4.15.5", diff --git a/apps/backend/src/appEnv.ts b/apps/backend/src/appEnv.ts index bedf2a1..2c2e32f 100644 --- a/apps/backend/src/appEnv.ts +++ b/apps/backend/src/appEnv.ts @@ -8,6 +8,7 @@ const envSchema = z.object({ DATABASE_URL: z.string(), ACCESS_TOKEN_SECRET: z.string(), REFRESH_TOKEN_SECRET: z.string(), + RESET_PASSWORD_TOKEN_SECRET: z.string(), COOKIE_SECRET: z.string(), }); diff --git a/apps/backend/src/drizzle/schema/users.ts b/apps/backend/src/drizzle/schema/users.ts index 0574af0..ff5fa03 100644 --- a/apps/backend/src/drizzle/schema/users.ts +++ b/apps/backend/src/drizzle/schema/users.ts @@ -20,6 +20,7 @@ export const users = pgTable("users", { email: varchar("email"), password: text("password").notNull(), isEnabled: boolean("isEnabled").default(true), + resetPasswordToken: varchar("resetPasswordToken"), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(), deletedAt: timestamp("deletedAt", { mode: "date" }), diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 14cf329..be57398 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -18,6 +18,7 @@ import HonoEnv from "./types/HonoEnv"; import devRoutes from "./routes/dev/route"; import appEnv from "./appEnv"; import questionsRoute from "./routes/questions/route"; +import forgotPasswordRoutes from "./routes/forgotPassword/route"; configDotenv(); @@ -84,6 +85,7 @@ const routes = app .route("/questions", questionsRoute) .route("/management-aspect", managementAspectsRoute) .route("/register", respondentsRoute) + .route("/forgot-password", forgotPasswordRoutes) .onError((err, c) => { if (err instanceof DashboardError) { return c.json( diff --git a/apps/backend/src/routes/forgotPassword/route.ts b/apps/backend/src/routes/forgotPassword/route.ts new file mode 100644 index 0000000..8c3ddb0 --- /dev/null +++ b/apps/backend/src/routes/forgotPassword/route.ts @@ -0,0 +1,111 @@ +import { zValidator } from "@hono/zod-validator"; +import HonoEnv from "../../types/HonoEnv"; +import { z } from "zod"; +import { and, eq, isNull } from "drizzle-orm"; +import { Hono } from "hono"; +import db from "../../drizzle"; +import { users } from "../../drizzle/schema/users"; +import { notFound, unauthorized } from "../../errors/DashboardError"; +import { generateResetPasswordToken, verifyResetPasswordToken } from "../../utils/authUtils"; +import { sendResetPasswordEmail } from "../../utils/mailerUtils"; +import { hashPassword } from "../../utils/passwordUtils"; + +const forgotPasswordRoutes = new Hono() + /** + * Forgot Password + * + * Checking emails in the database, generating tokens, and sending emails occurs. + */ + .post( + '/', + zValidator( + 'json', + z.object({ + email: z.string().email(), + }) + ), + async (c) => { + const { email } = c.req.valid('json'); + + const user = await db + .select() + .from(users) + .where( + and( + isNull(users.deletedAt), + eq(users.email, email) + ) + ); + + if (!user.length) throw notFound(); + + // Generate reset password token + const resetPasswordToken = await generateResetPasswordToken({ + uid: user[0].id, + }); + + await db + .update(users) + .set({ + resetPasswordToken: resetPasswordToken + }) + .where(eq(users.email, email)); + + // Send email with reset password token + await sendResetPasswordEmail(email, resetPasswordToken); + + return c.json({ + message: 'Email has been sent successfully', + }); + } + ) + /** + * Reset Password + */ + .patch( + '/verify', + zValidator( + 'json', + z.object({ + password: z.string(), + confirm_password: z.string() + }) + ), + async (c) => { + const formData = c.req.valid('json'); + const token = c.req.query('token') + + // Token validation + if (!token) { + return c.json({ message: 'Token is required' }, 400); + } + + // Password validation + if (formData.password !== formData.confirm_password) { + return c.json({ message: 'Passwords do not match' }, 400); + } + + const decoded = await verifyResetPasswordToken(token); + if (!decoded) { + return c.json({ message: 'Invalid or expired token' }, 401); + } + + if (!decoded) throw unauthorized(); + + // Hash the password + const hashedPassword = await hashPassword(formData.password); + + await db + .update(users) + .set({ + password: hashedPassword, + updatedAt: new Date(), + }) + .where(eq(users.id, decoded.uid)); + + return c.json({ + message: 'Password has been reset successfully' + }); + }); + +export default forgotPasswordRoutes; \ No newline at end of file diff --git a/apps/backend/src/utils/authUtils.ts b/apps/backend/src/utils/authUtils.ts index 99019f4..09f19b3 100644 --- a/apps/backend/src/utils/authUtils.ts +++ b/apps/backend/src/utils/authUtils.ts @@ -4,6 +4,7 @@ import appEnv from "../appEnv"; // Environment variables for secrets, defaulting to a random secret if not set. const accessTokenSecret = appEnv.ACCESS_TOKEN_SECRET; const refreshTokenSecret = appEnv.REFRESH_TOKEN_SECRET; +const resetPasswordTokenSecret = appEnv.RESET_PASSWORD_TOKEN_SECRET; // Algorithm to be used for JWT encoding. const algorithm: jwt.Algorithm = "HS256"; @@ -11,6 +12,7 @@ const algorithm: jwt.Algorithm = "HS256"; // Expiry settings for tokens. 'null' signifies no expiry. export const accessTokenExpiry: number | string | null = null; export const refreshTokenExpiry: number | string | null = "30d"; +export const resetPasswordTokenExpiry: number | string | null = null; // Interfaces to describe the payload structure for access and refresh tokens. interface AccessTokenPayload { @@ -21,6 +23,10 @@ interface RefreshTokenPayload { uid: string; } +interface ResetPasswordTokenPayload { + uid: string; +} + /** * Generates a JSON Web Token (JWT) for access control using a specified payload. * @@ -84,3 +90,35 @@ export const verifyRefreshToken = async (token: string) => { return null; } }; + +/** + * Generates a JSON Web Token (JWT) for reset password using a specified payload. + * + * @param payload - The payload containing user-specific data for the token. + * @returns A promise that resolves to the generated JWT string. + */ +export const generateResetPasswordToken = async (payload: ResetPasswordTokenPayload) => { + const token = jwt.sign(payload, resetPasswordTokenSecret, { + algorithm, + ...(resetPasswordTokenExpiry ? { expiresIn: resetPasswordTokenExpiry } : {}), + }); + return token; +}; + +/** + * Verifies a given reset password token and decodes the payload if the token is valid. + * + * @param token - The JWT string to verify. + * @returns A promise that resolves to the decoded payload or null if verification fails. + */ +export const verifyResetPasswordToken = async (token: string) => { + try { + const payload = jwt.verify( + token, + resetPasswordTokenSecret + ) as ResetPasswordTokenPayload; + return payload; + } catch { + return null; + } +}; \ No newline at end of file diff --git a/apps/backend/src/utils/mailerUtils.ts b/apps/backend/src/utils/mailerUtils.ts new file mode 100644 index 0000000..fa40740 --- /dev/null +++ b/apps/backend/src/utils/mailerUtils.ts @@ -0,0 +1,32 @@ +import nodemailer from 'nodemailer'; + +/** + * Nodemailer configuration + */ +const transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 587, + secure: false, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + tls: { + rejectUnauthorized: false, + }, +}); + +export async function sendResetPasswordEmail(to: string, token: string) { + const resetUrl = `https://localhost:3000/forgot-password/verify?token=${token}`; + + const info = await transporter.sendMail({ + from: '"Your App" <${process.env.EMAIL_USER}>', + to, + subject: 'Password Reset Request', + text: `You requested a password reset. Click this link to reset your password: ${resetUrl}`, + html: `

You requested a password reset. Click this link to reset your password:
${resetUrl}

`, + }); + + console.log('Email sent: %s', info.messageId); + return info; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16570cb..1659c27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: moment: specifier: ^2.30.1 version: 2.30.1 + nodemailer: + specifier: ^6.9.14 + version: 6.9.14 postgres: specifier: ^3.4.4 version: 3.4.4 @@ -69,6 +72,9 @@ importers: '@types/node': specifier: ^20.14.2 version: 20.14.2 + '@types/nodemailer': + specifier: ^6.4.15 + version: 6.4.15 drizzle-kit: specifier: ^0.22.7 version: 0.22.7 @@ -1124,6 +1130,7 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -1131,6 +1138,7 @@ packages: '@humanwhocodes/object-schema@2.0.2': resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead '@humanwhocodes/retry@0.3.0': resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} @@ -1759,6 +1767,9 @@ packages: '@types/node@20.14.2': resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} + '@types/nodemailer@6.4.15': + resolution: {integrity: sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3226,6 +3237,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -3377,6 +3389,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4047,6 +4060,10 @@ packages: node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + nodemailer@6.9.14: + resolution: {integrity: sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==} + engines: {node: '>=6.0.0'} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -4561,6 +4578,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rollup@4.18.0: @@ -6760,7 +6778,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.11.24 + '@types/node': 20.14.2 '@types/graceful-fs@4.1.9': dependencies: @@ -6805,6 +6823,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/nodemailer@6.4.15': + dependencies: + '@types/node': 20.14.2 + '@types/normalize-package-data@2.4.4': {} '@types/parse-json@4.0.2': {} @@ -6841,7 +6863,7 @@ snapshots: '@types/through@0.0.30': dependencies: - '@types/node': 20.11.24 + '@types/node': 20.14.2 '@types/tinycolor2@1.4.6': {} @@ -9717,6 +9739,8 @@ snapshots: node-releases@2.0.14: {} + nodemailer@6.9.14: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 From 4f83c6f6948eb9ef4519a141c93b82ce1f4516ec Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Fri, 23 Aug 2024 09:35:11 +0700 Subject: [PATCH 13/15] create: API for assessment request --- apps/backend/src/data/permissions.ts | 6 + apps/backend/src/index.ts | 2 + .../src/routes/assessmentRequest/route.ts | 105 ++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 apps/backend/src/routes/assessmentRequest/route.ts diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index a048ea3..bb8eac4 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -62,6 +62,12 @@ const permissionsData = [ { code: "managementAspect.restore", }, + { + code: "assessmentRequest.read", + }, + { + code: "assessmentRequest.create", + }, ] as const; export type SpecificPermissionCode = (typeof permissionsData)[number]["code"]; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 14cf329..2dbd068 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -18,6 +18,7 @@ import HonoEnv from "./types/HonoEnv"; import devRoutes from "./routes/dev/route"; import appEnv from "./appEnv"; import questionsRoute from "./routes/questions/route"; +import assessmentRequestRoute from "./routes/assessmentRequest/route"; configDotenv(); @@ -84,6 +85,7 @@ const routes = app .route("/questions", questionsRoute) .route("/management-aspect", managementAspectsRoute) .route("/register", respondentsRoute) + .route("/assessmentRequest", assessmentRequestRoute) .onError((err, c) => { if (err instanceof DashboardError) { return c.json( diff --git a/apps/backend/src/routes/assessmentRequest/route.ts b/apps/backend/src/routes/assessmentRequest/route.ts new file mode 100644 index 0000000..3f189a9 --- /dev/null +++ b/apps/backend/src/routes/assessmentRequest/route.ts @@ -0,0 +1,105 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import db from "../../drizzle"; +import { respondents } from "../../drizzle/schema/respondents"; +import { assessments } from "../../drizzle/schema/assessments"; +import { users } from "../../drizzle/schema/users"; +import { rolesToUsers } from "../../drizzle/schema/rolesToUsers"; +import { rolesSchema } from "../../drizzle/schema/roles"; +import HonoEnv from "../../types/HonoEnv"; +import authInfo from "../../middlewares/authInfo"; +import { notFound } from "../../errors/DashboardError"; +import checkPermission from "../../middlewares/checkPermission"; +import requestValidator from "../../utils/requestValidator"; +import { HTTPException } from "hono/http-exception"; +import { createId } from "@paralleldrive/cuid2"; + +const assessmentRequestRoute = new Hono() + .use(authInfo) + + // Get assessment request by user ID + .get( + "/:id", + checkPermission("assessmentRequest.read"), + requestValidator( + "query", + z.object({ + includeTrashed: z.string().default("false"), + }) + ), + async (c) => { + const userId = c.req.param("id"); + + const queryResult = await db + .select({ + userId: users.id, + createdAt: assessments.createdAt, + name: users.name, + code: rolesSchema.code, + status: assessments.status, + }) + .from(users) + .leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId)) + .leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id)) + .leftJoin(respondents, eq(users.id, respondents.userId)) + .leftJoin(assessments, eq(respondents.id, assessments.respondentId)) + .where(eq(users.id, userId)); + + if (!queryResult[0]) throw notFound(); + + const assessmentRequestData = { + ...queryResult, + }; + + return c.json(assessmentRequestData); + } + ) + + // Post assessment request by user ID + .post( + "/:id", + checkPermission("assessmentRequest.create"), + requestValidator( + "json", + z.object({ + respondentId: z.string().min(1), + }) + ), + async (c) => { + const { respondentId } = c.req.valid("json"); + const userId = c.req.param("id"); + + // Make sure the userId exists + if (!userId) { + throw new HTTPException(400, { message: "User ID is required." }); + } + + // Validate if respondent exists + const respondent = await db + .select() + .from(respondents) + .where(eq(respondents.id, respondentId)); + + if (!respondent.length) { + throw new HTTPException(404, { message: "Respondent not found." }); + } + + // Create the assessment request + const newAssessment = await db + .insert(assessments) + .values({ + id: createId(), + respondentId, + status: "menunggu konfirmasi", + validatedBy: null, + validatedAt: null, + createdAt: new Date(), + }) + .returning(); + + return c.json({ message: "Successfully submitted the assessment request" }, 201); + } + ); + +export default assessmentRequestRoute; \ No newline at end of file From a4000047d6412f788a9ef30ae89cc7e6ed7be91f Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Fri, 23 Aug 2024 10:02:48 +0700 Subject: [PATCH 14/15] fix: smtp and appEnv --- apps/backend/.env.example | 9 ++++++++- apps/backend/src/appEnv.ts | 5 +++++ apps/backend/src/utils/mailerUtils.ts | 13 +++++++------ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 1dd79ad..f06dab1 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,7 +1,14 @@ +BASE_URL = APP_PORT = 3000 DATABASE_URL = ACCESS_TOKEN_SECRET = REFRESH_TOKEN_SECRET = -COOKIE_SECRET = \ No newline at end of file +RESET_PASSWORD_TOKEN_SECRET = +COOKIE_SECRET = + +SMTP_USERNAME = +SMTP_PASSWORD = +SMTP_HOST = +SMTP_PORT = \ No newline at end of file diff --git a/apps/backend/src/appEnv.ts b/apps/backend/src/appEnv.ts index 2c2e32f..9d4672a 100644 --- a/apps/backend/src/appEnv.ts +++ b/apps/backend/src/appEnv.ts @@ -4,12 +4,17 @@ import { z } from "zod"; dotenv.config(); const envSchema = z.object({ + BASE_URL: z.string(), APP_PORT: z.coerce.number().int(), DATABASE_URL: z.string(), ACCESS_TOKEN_SECRET: z.string(), REFRESH_TOKEN_SECRET: z.string(), RESET_PASSWORD_TOKEN_SECRET: z.string(), COOKIE_SECRET: z.string(), + SMTP_USERNAME: z.string(), + SMTP_PASSWORD: z.string(), + SMTP_HOST: z.string(), + SMTP_PORT: z.coerce.number().int(), }); const parsedEnv = envSchema.safeParse(process.env); diff --git a/apps/backend/src/utils/mailerUtils.ts b/apps/backend/src/utils/mailerUtils.ts index fa40740..4f5d660 100644 --- a/apps/backend/src/utils/mailerUtils.ts +++ b/apps/backend/src/utils/mailerUtils.ts @@ -1,15 +1,16 @@ import nodemailer from 'nodemailer'; +import appEnv from '../appEnv'; /** * Nodemailer configuration */ const transporter = nodemailer.createTransport({ - host: 'smtp.gmail.com', - port: 587, + host: appEnv.SMTP_HOST, + port: appEnv.SMTP_PORT, secure: false, auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, + user: appEnv.SMTP_USERNAME, + pass: appEnv.SMTP_PASSWORD, }, tls: { rejectUnauthorized: false, @@ -17,10 +18,10 @@ const transporter = nodemailer.createTransport({ }); export async function sendResetPasswordEmail(to: string, token: string) { - const resetUrl = `https://localhost:3000/forgot-password/verify?token=${token}`; + const resetUrl = appEnv.BASE_URL + '/forgot-password/verify?token=' + token; const info = await transporter.sendMail({ - from: '"Your App" <${process.env.EMAIL_USER}>', + from: `"Your App" <${appEnv.SMTP_USERNAME}>`, to, subject: 'Password Reset Request', text: `You requested a password reset. Click this link to reset your password: ${resetUrl}`, From 5a2fb8126173e88b6c4d74d26cd88eb164a91899 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 30 Aug 2024 14:22:35 +0700 Subject: [PATCH 15/15] update: add Endpoint to Calculate Average Score By Sub Aspect and Aspect --- apps/backend/src/routes/assessments/route.ts | 130 ++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 02781f5..604c903 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -7,6 +7,7 @@ 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"; @@ -403,6 +404,133 @@ const assessmentsRoute = new Hono() 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", + 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;