From 8e03b61e1d28fb61cd33884c43b7e3c4a4c78b9d Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Fri, 9 Aug 2024 09:43:57 +0700 Subject: [PATCH 1/4] create: questions management API --- apps/backend/src/data/permissions.ts | 15 ++ apps/backend/src/data/sidebarMenus.ts | 7 + apps/backend/src/index.ts | 2 + apps/backend/src/routes/questions/route.ts | 291 +++++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 apps/backend/src/routes/questions/route.ts diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index 1e91356..e4d458c 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -32,6 +32,21 @@ const permissionsData = [ { code: "roles.delete", }, + { + code: "questions.readAll", + }, + { + code: "questions.create", + }, + { + code: "questions.update", + }, + { + code: "questions.delete", + }, + { + code: "questions.restore", + }, ] as const; export type SpecificPermissionCode = (typeof permissionsData)[number]["code"]; diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index 2de9dd6..ec98d7e 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -14,6 +14,13 @@ const sidebarMenus: SidebarMenu[] = [ link: "/users", color: "red", }, + { + label: "Manajemen Pertanyaan", + icon: { tb: "TbChecklist" }, + allowedPermissions: ["permissions.read"], + link: "/questions", + color: "green", + }, ]; export default sidebarMenus; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index c0fbe56..9076b79 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 questionsRoute from "./routes/questions/route"; configDotenv(); @@ -78,6 +79,7 @@ const routes = app .route("/dashboard", dashboardRoutes) .route("/roles", rolesRoute) .route("/dev", devRoutes) + .route("/questions", questionsRoute) .onError((err, c) => { if (err instanceof DashboardError) { return c.json( diff --git a/apps/backend/src/routes/questions/route.ts b/apps/backend/src/routes/questions/route.ts new file mode 100644 index 0000000..8510828 --- /dev/null +++ b/apps/backend/src/routes/questions/route.ts @@ -0,0 +1,291 @@ +import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; +import { Hono } from "hono"; + +import { z } from "zod"; +import { HTTPException } from "hono/http-exception"; +import db from "../../drizzle"; +import { questions } from "../../drizzle/schema/questions"; +import HonoEnv from "../../types/HonoEnv"; +import requestValidator from "../../utils/requestValidator"; +import authInfo from "../../middlewares/authInfo"; +import checkPermission from "../../middlewares/checkPermission"; +import { aspects } from "../../drizzle/schema/aspects"; +import { subAspects } from "../../drizzle/schema/subAspects"; + +export const questionFormSchema = z.object({ + subAspectId: z.string().min(1).max(255), + question: z.string().min(1).max(255), + needFile: z.boolean().default(false), +}); + +export const questionUpdateSchema = questionFormSchema.extend({ + question: z.string().min(1).max(255).optional().or(z.literal("")), + subAspectiD: z.string().min(1).max(255).optional().or(z.literal("")), + needFile: z.boolean().default(false).optional().or(z.boolean()), +}); + +const questionsRoute = new Hono() + .use(authInfo) + /** + * Get All Questions (With Metadata) + * + * Query params: + * - includeTrashed: boolean (default: false)\ + * - withMetadata: boolean + */ + .get( + "/", + checkPermission("questions.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(30), + q: z.string().default(""), + }) + ), + async (c) => { + const { includeTrashed, page, limit, q } = c.req.valid("query"); + + const totalCountQuery = includeTrashed + ? sql`(SELECT count(*) FROM ${questions})` + : sql`(SELECT count(*) FROM ${questions} WHERE ${questions.deletedAt} IS NULL)`; + + const result = await db + .select({ + id: questions.id, + question: questions.question, + needFile: questions.needFile, + aspectName: aspects.name, + subAspectName: subAspects.name, + createdAt: questions.createdAt, + updatedAt: questions.updatedAt, + ...(includeTrashed ? { deletedAt: questions.deletedAt } : {}), + fullCount: totalCountQuery, + }) + .from(questions) + .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .where( + and( + includeTrashed ? undefined : isNull(questions.deletedAt), + q + ? or( + ilike(subAspects.aspectId, q), + ilike(questions.subAspectId, q), + ilike(questions.question, q), + ilike(questions.needFile, q), + ilike(questions.createdAt, q), + ilike(questions.updatedAt, q), + ilike(questions.deletedAt, q), + eq(questions.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 user by id + .get( + "/:id", + checkPermission("questions.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z.string().default("false"), + }) + ), + async (c) => { + const questionId = c.req.param("id"); + + const includeTrashed = + c.req.query("includeTrashed")?.toLowerCase() === "true"; + + const queryResult = await db + .select({ + id: questions.id, + question: questions.question, + needFile: questions.needFile, + aspectName: aspects.name, + subAspectName: subAspects.name, + createdAt: questions.createdAt, + updatedAt: questions.updatedAt, + ...(includeTrashed ? { deletedAt: questions.deletedAt } : {}), + }) + .from(questions) + .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .where( + and( + eq(questions.id, questionId), + !includeTrashed ? isNull(questions.deletedAt) : undefined + ) + ); + + if (!queryResult.length) + throw new HTTPException(404, { + message: "The question does not exists", + }); + + const userData = { + ...queryResult[0], + }; + + return c.json(userData); + } + ) + // create question + .post( + "/", + checkPermission("questions.create"), + requestValidator("json", questionFormSchema), + async (c) => { + const questionData = c.req.valid("json"); + + const question = await db + .insert(questions) + .values({ + question: questionData.question, + needFile: questionData.needFile, + subAspectId: questionData.subAspectId, + }) + .returning(); + + return c.json( + { + message: "Question created successfully", + data: question, + }, + 201 + ); + } + ) + + //update question + .patch( + "/:id", + checkPermission("questions.update"), + requestValidator("json", questionUpdateSchema), + async (c) => { + const questionId = c.req.param("id"); + const questionData = c.req.valid("json"); + + const question = await db + .select() + .from(questions) + .where(and(eq(questions.id, questionId), isNull(questions.deletedAt))); + + if (!question[0]) return c.notFound(); + + await db + .update(questions) + .set({ + ...questionData, + updatedAt: new Date(), + }) + .where(eq(questions.id, questionId)); + + return c.json({ + message: "Question updated successfully", + }); + } + ) + + //delete user + .delete( + "/:id", + checkPermission("questions.delete"), + requestValidator( + "query", + z.object({ + skipTrash: z.string().default("false"), + }) + ), + async (c) => { + const questionId = c.req.param("id"); + + const skipTrash = + c.req.valid("query").skipTrash.toLowerCase() === "true"; + + const question = await db + .select() + .from(questions) + .where( + and( + eq(questions.id, questionId), + skipTrash ? undefined : isNull(questions.deletedAt) + ) + ); + + if (!question[0]) + throw new HTTPException(404, { + message: "The question is not found", + }); + + if (skipTrash) { + await db.delete(questions).where(eq(questions.id, questionId)); + } else { + await db + .update(questions) + .set({ + deletedAt: new Date(), + }) + .where(and(eq(questions.id, questionId), isNull(questions.deletedAt))); + } + return c.json({ + message: "Question deleted successfully", + }); + } + ) + + // undo delete + .patch("/restore/:id", + checkPermission("questions.restore"), + async (c) => { + const questionId = c.req.param("id"); + + const question = ( + await db.select().from(questions).where(eq(questions.id, questionId)) + )[0]; + + if (!question) return c.notFound(); + + if (!question.deletedAt) { + throw new HTTPException(400, { + message: "The question is not deleted", + }); + } + + await db + .update(questions) + .set({ deletedAt: null }) + .where(eq(questions.id, questionId)); + + return c.json({ + message: "Question restored successfully", + }); + }); + +export default questionsRoute; From f9d7e0398d1530bf4c7118504a1fe1d3f09d8a32 Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Wed, 14 Aug 2024 10:22:01 +0700 Subject: [PATCH 2/4] fix: revise questions management --- apps/backend/src/routes/questions/route.ts | 59 +++++++++++++++------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/routes/questions/route.ts b/apps/backend/src/routes/questions/route.ts index 8510828..ed556cf 100644 --- a/apps/backend/src/routes/questions/route.ts +++ b/apps/backend/src/routes/questions/route.ts @@ -11,6 +11,7 @@ import authInfo from "../../middlewares/authInfo"; import checkPermission from "../../middlewares/checkPermission"; import { aspects } from "../../drizzle/schema/aspects"; import { subAspects } from "../../drizzle/schema/subAspects"; +import { notFound } from "../../errors/DashboardError"; export const questionFormSchema = z.object({ subAspectId: z.string().min(1).max(255), @@ -19,9 +20,9 @@ export const questionFormSchema = z.object({ }); export const questionUpdateSchema = questionFormSchema.extend({ - question: z.string().min(1).max(255).optional().or(z.literal("")), - subAspectiD: z.string().min(1).max(255).optional().or(z.literal("")), - needFile: z.boolean().default(false).optional().or(z.boolean()), + question: z.string().min(1).max(255).or(z.literal("")), + subAspectId: z.string().min(1).max(255).or(z.literal("")), + needFile: z.boolean().default(false).or(z.boolean()), }); const questionsRoute = new Hono() @@ -30,7 +31,7 @@ const questionsRoute = new Hono() * Get All Questions (With Metadata) * * Query params: - * - includeTrashed: boolean (default: false)\ + * - includeTrashed: boolean (default: false) * - withMetadata: boolean */ .get( @@ -79,10 +80,6 @@ const questionsRoute = new Hono() includeTrashed ? undefined : isNull(questions.deletedAt), q ? or( - ilike(subAspects.aspectId, q), - ilike(questions.subAspectId, q), - ilike(questions.question, q), - ilike(questions.needFile, q), ilike(questions.createdAt, q), ilike(questions.updatedAt, q), ilike(questions.deletedAt, q), @@ -107,7 +104,13 @@ const questionsRoute = new Hono() }); } ) - // get user by id + /** + * Get Question by ID + * + * Query params: + * - id: string + * - includeTrashed: boolean (default: false) + */ .get( "/:id", checkPermission("questions.readAll"), @@ -149,14 +152,19 @@ const questionsRoute = new Hono() message: "The question does not exists", }); - const userData = { + const questionData = { ...queryResult[0], }; - return c.json(userData); + return c.json(questionData); } ) - // create question + /** + * Create Question + * + * JSON: + * - questionFormSchema: object + */ .post( "/", checkPermission("questions.create"), @@ -182,8 +190,12 @@ const questionsRoute = new Hono() ); } ) - - //update question + /** + * Update Question + * + * JSON: + * - questionUpdateSchema: object + */ .patch( "/:id", checkPermission("questions.update"), @@ -197,7 +209,7 @@ const questionsRoute = new Hono() .from(questions) .where(and(eq(questions.id, questionId), isNull(questions.deletedAt))); - if (!question[0]) return c.notFound(); + if (!question[0]) throw notFound(); await db .update(questions) @@ -212,8 +224,13 @@ const questionsRoute = new Hono() }); } ) - - //delete user + /** + * Delete Question + * + * Query params: + * - id: string + * - skipTrash: string (default: false) + */ .delete( "/:id", checkPermission("questions.delete"), @@ -259,8 +276,12 @@ const questionsRoute = new Hono() }); } ) - - // undo delete + /** + * Restore Question + * + * Query params: + * - id: string + */ .patch("/restore/:id", checkPermission("questions.restore"), async (c) => { From c2ae3ec0e3191ffc8fde4a1fbadc1439efcb6631 Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Wed, 14 Aug 2024 10:28:41 +0700 Subject: [PATCH 3/4] fix: revise throw error --- apps/backend/src/routes/questions/route.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/routes/questions/route.ts b/apps/backend/src/routes/questions/route.ts index ed556cf..d605c58 100644 --- a/apps/backend/src/routes/questions/route.ts +++ b/apps/backend/src/routes/questions/route.ts @@ -147,10 +147,7 @@ const questionsRoute = new Hono() ) ); - if (!queryResult.length) - throw new HTTPException(404, { - message: "The question does not exists", - }); + if (!queryResult[0]) throw notFound(); const questionData = { ...queryResult[0], @@ -256,10 +253,7 @@ const questionsRoute = new Hono() ) ); - if (!question[0]) - throw new HTTPException(404, { - message: "The question is not found", - }); + if (!question[0]) throw notFound(); if (skipTrash) { await db.delete(questions).where(eq(questions.id, questionId)); @@ -291,7 +285,7 @@ const questionsRoute = new Hono() await db.select().from(questions).where(eq(questions.id, questionId)) )[0]; - if (!question) return c.notFound(); + if (!question) throw notFound(); if (!question.deletedAt) { throw new HTTPException(400, { From c225c22112b9f319f0f1972df6129e87176fb29a Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Fri, 16 Aug 2024 16:02:37 +0700 Subject: [PATCH 4/4] fix: API get question by id --- apps/backend/src/routes/questions/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/routes/questions/route.ts b/apps/backend/src/routes/questions/route.ts index d605c58..f0bee0f 100644 --- a/apps/backend/src/routes/questions/route.ts +++ b/apps/backend/src/routes/questions/route.ts @@ -131,8 +131,8 @@ const questionsRoute = new Hono() id: questions.id, question: questions.question, needFile: questions.needFile, - aspectName: aspects.name, - subAspectName: subAspects.name, + aspectId: aspects.id, + subAspectId: subAspects.id, createdAt: questions.createdAt, updatedAt: questions.updatedAt, ...(includeTrashed ? { deletedAt: questions.deletedAt } : {}),