From 8e03b61e1d28fb61cd33884c43b7e3c4a4c78b9d Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Fri, 9 Aug 2024 09:43:57 +0700 Subject: [PATCH] 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;