diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index 8c2f443..1951651 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -47,6 +47,21 @@ const permissionsData = [ { code: "questions.restore", }, + { + code: "managementAspect.readAll", + }, + { + code: "managementAspect.create", + }, + { + code: "managementAspect.update", + }, + { + code: "managementAspect.delete", + }, + { + code: "managementAspect.restore", + }, { code: "assessments.readAssessmentScore", }, diff --git a/apps/backend/src/data/roles.ts b/apps/backend/src/data/roles.ts index 2c42327..d1c46bb 100644 --- a/apps/backend/src/data/roles.ts +++ b/apps/backend/src/data/roles.ts @@ -17,10 +17,18 @@ const roleData: RoleData[] = [ name: "Super Admin", permissions: permissionsData.map((permission) => permission.code), }, + { + code: "user", + description: + "User with standard access rights for general usage of the application.", + isActive: true, + name: "User", + permissions: permissionsData.map((permission) => permission.code), + }, ]; // Manually specify the union of role codes -export type RoleCode = "super-admin" | "*"; +export type RoleCode = "super-admin" | "user" | "*"; const exportedRoleData = roleData; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 8f41a10..df75b82 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -3,6 +3,8 @@ import { configDotenv } from "dotenv"; import { Hono } from "hono"; import authRoutes from "./routes/auth/route"; import usersRoute from "./routes/users/route"; +import managementAspectsRoute from "./routes/managementAspect/route"; +import respondentsRoute from "./routes/register/route"; import { verifyAccessToken } from "./utils/authUtils"; import permissionRoutes from "./routes/permissions/route"; import { cors } from "hono/cors"; @@ -81,6 +83,8 @@ const routes = app .route("/roles", rolesRoute) .route("/dev", devRoutes) .route("/questions", questionsRoute) + .route("/management-aspect", managementAspectsRoute) + .route("/register", respondentsRoute) .route("/assessments", assessmentsRoute) .onError((err, c) => { if (err instanceof DashboardError) { diff --git a/apps/backend/src/routes/managementAspect/route.ts b/apps/backend/src/routes/managementAspect/route.ts new file mode 100644 index 0000000..da8a140 --- /dev/null +++ b/apps/backend/src/routes/managementAspect/route.ts @@ -0,0 +1,492 @@ +import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; +import { Hono } from "hono"; + +import { z } from "zod"; +import db from "../../drizzle"; +import { aspects } from "../../drizzle/schema/aspects"; +import { subAspects } from "../../drizzle/schema/subAspects"; +import HonoEnv from "../../types/HonoEnv"; +import requestValidator from "../../utils/requestValidator"; +import authInfo from "../../middlewares/authInfo"; +import checkPermission from "../../middlewares/checkPermission"; +import { forbidden } from "../../errors/DashboardError"; +import { notFound } from "../../errors/DashboardError"; + +// Schema for creating and updating aspects +export const aspectFormSchema = z.object({ + name: z.string().min(1).max(50), + subAspects: z + .string() + .refine( + (data) => { + try { + const parsed = JSON.parse(data); + return Array.isArray(parsed); + } catch { + return false; + } + }, + { + message: "Sub Aspects must be an array", + } + ) + .optional(), +}); + +export const aspectUpdateSchema = aspectFormSchema.extend({ + subAspects: z.string().optional().or(z.literal("")), +}); + +// Schema for creating and updating subAspects +export const subAspectFormSchema = z.object({ + name: z.string().min(1).max(50), + aspectId: z.string().uuid(), +}); + +export const subAspectUpdateSchema = subAspectFormSchema.extend({}); + +const managementAspectRoute = new Hono() + .use(authInfo) + /** + * Get All Aspects (With Metadata) + * + * Query params: + * - includeTrashed: boolean (default: false) + * - withMetadata: boolean + */ + + // Get all aspects + .get( + "/", + checkPermission("managementAspect.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(10), + q: z.string().default(""), + }) + ), + async (c) => { + const { includeTrashed, page, limit, q } = c.req.valid("query"); + + const totalCountQuery = includeTrashed + ? sql`(SELECT count(*) FROM ${aspects})` + : sql`(SELECT count(*) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; + + const result = await db + .select({ + id: aspects.id, + name: aspects.name, + createdAt: aspects.createdAt, + updatedAt: aspects.updatedAt, + ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), + fullCount: totalCountQuery, + }) + .from(aspects) + .where( + and( + includeTrashed ? undefined : isNull(aspects.deletedAt), + q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined + ) + ) + .offset(page * limit) + .limit(limit); + + 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 aspect by id + .get( + "/:id", + checkPermission("managementAspect.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z.string().default("false"), + }) + ), + async (c) => { + const aspectId = c.req.param("id"); + + if (!aspectId) + throw notFound({ + message: "Missing id", + }); + + const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true"; + + const queryResult = await db + .select({ + id: aspects.id, + name: aspects.name, + createdAt: aspects.createdAt, + updatedAt: aspects.updatedAt, + ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), + subAspect: { + name: subAspects.name, + id: subAspects.id, + }, + }) + .from(aspects) + .leftJoin(subAspects, eq(aspects.id, subAspects.aspectId)) + .where(and(eq(aspects.id, aspectId), !includeTrashed ? isNull(aspects.deletedAt) : undefined)); + + if (!queryResult.length) + throw forbidden({ + message: "The aspect does not exist", + }); + + const subAspectsList = queryResult.reduce((prev, curr) => { + if (!curr.subAspect) return prev; + prev.set(curr.subAspect.id, curr.subAspect.name); + return prev; + }, new Map()); // Map + + const aspectData = { + ...queryResult[0], + subAspect: undefined, + subAspects: Array.from(subAspectsList, ([id, name]) => ({ id, name })), + }; + + return c.json(aspectData); + } + ) + + // Create aspect + .post("/", + checkPermission("managementAspect.create"), + requestValidator("json", aspectFormSchema), + async (c) => { + const aspectData = c.req.valid("json"); + + // Validation to check if the aspect name already exists + const existingAspect = await db + .select() + .from(aspects) + .where(eq(aspects.name, aspectData.name)); + + if (existingAspect.length > 0) { + throw forbidden({ + message: "Aspect name already exists", + }); + } + + const aspect = await db + .insert(aspects) + .values({ + name: aspectData.name, + }) + .returning(); + + // if sub-aspects are available, parse them into a string array + if (aspectData.subAspects) { + const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; + // if there are sub-aspects, insert them into the database + if (subAspectsArray.length) { + await db.insert(subAspects).values( + subAspectsArray.map((subAspect) => ({ + aspectId: aspect[0].id, + name: subAspect, + })) + ); + } + } + + return c.json( + { + message: "Aspect created successfully", + }, + 201 + ); + } + ) + + // Update aspect + .patch( + "/:id", + checkPermission("managementAspect.update"), + requestValidator("json", aspectUpdateSchema), + async (c) => { + const aspectId = c.req.param("id"); + const aspectData = c.req.valid("json"); + + // Validation to check if the new aspect name already exists + const existingAspect = await db + .select() + .from(aspects) + .where( + and( + eq(aspects.name, aspectData.name), + isNull(aspects.deletedAt), + sql`${aspects.id} <> ${aspectId}` + ) + ); + + if (existingAspect.length > 0) { + throw forbidden({ + message: "Aspect name already exists", + }); + } + + const aspect = await db + .select() + .from(aspects) + .where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt))); + + if (!aspect[0]) throw notFound(); + + await db + .update(aspects) + .set({ + ...aspectData, + updatedAt: new Date(), + }) + .where(eq(aspects.id, aspectId)); + + //Update for Sub-Aspects + // if (aspectData.subAspects) { + // const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; + + // await db.delete(subAspects).where(eq(subAspects.aspectId, aspectId)); + + // if (subAspectsArray.length) { + // await db.insert(subAspects).values( + // subAspectsArray.map((subAspect) => ({ + // aspectId: aspectId, + // name: subAspect, + // })) + // ); + // } + // } + + return c.json({ + message: "Aspect updated successfully", + }); + } + ) + + // Delete aspect + .delete( + "/:id", + checkPermission("managementAspect.delete"), + requestValidator( + "form", + z.object({ + // skipTrash: z.string().default("false"), + }) + ), + async (c) => { + const aspectId = c.req.param("id"); + + // const skipTrash = c.req.valid("form").skipTrash.toLowerCase() === "true"; + + const aspect = await db + .select() + .from(aspects) + .where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt))); + + if (!aspect[0]) + throw notFound({ + message: "The aspect is not found", + }); + + await db + .update(aspects) + .set({ + deletedAt: new Date(), + }) + .where(eq(aspects.id, aspectId)); + + return c.json({ + message: "Aspect deleted successfully", + }); + } + ) + + // Undo delete + .patch( + "/restore/:id", + checkPermission("managementAspect.restore"), + async (c) => { + const aspectId = c.req.param("id"); + + const aspect = (await db.select().from(aspects).where(eq(aspects.id, aspectId)))[0]; + + if (!aspect) throw notFound(); + + if (!aspect.deletedAt) { + throw forbidden({ + message: "The aspect is not deleted", + }); + } + + await db.update(aspects).set({ deletedAt: null }).where(eq(aspects.id, aspectId)); + + return c.json({ + message: "Aspect restored successfully", + }); + } + ) + + // Get sub aspects by aspect ID + .get( + "/subAspects/:aspectId", + checkPermission("managementAspect.readAll"), + async (c) => { + const aspectId = c.req.param("aspectId"); + + const aspect = await db + .select() + .from(aspects) + .where(and( + eq(aspects.id, aspectId), + isNull(aspects.deletedAt))); + + if (!aspect[0]) + throw notFound({ + message: "The aspect is not found", + }); + + const subAspectsData = await db + .select() + .from(subAspects) + .where(eq(subAspects.aspectId, aspectId)); + + return c.json({ + subAspects: subAspectsData, + }); + } + ) + + // Create sub aspect + .post( + "/subAspect", + checkPermission("managementAspect.create"), + requestValidator("json", subAspectFormSchema), + async (c) => { + const subAspectData = c.req.valid("json"); + + // Validation to check if the sub aspect name already exists + const existingSubAspect = await db + .select() + .from(subAspects) + .where( + and( + eq(subAspects.name, subAspectData.name), + eq(subAspects.aspectId, subAspectData.aspectId))); + + if (existingSubAspect.length > 0) { + throw forbidden({ message: "Nama Sub Aspek sudah tersedia!" }); + } + + const [aspect] = await db + .select() + .from(aspects) + .where( + and( + eq(aspects.id, subAspectData.aspectId), + isNull(aspects.deletedAt))); + + if (!aspect) + throw forbidden({ + message: "The aspect is not found", + }); + + await db.insert(subAspects).values(subAspectData); + + return c.json( + { + message: "Sub aspect created successfully", + }, + 201 + ); + } + ) + + // Update sub aspect + .patch( + "/subAspect/:id", checkPermission("managementAspect.update"), + requestValidator("json", subAspectUpdateSchema), + async (c) => { + const subAspectId = c.req.param("id"); + const subAspectData = c.req.valid("json"); + + // Validation to check if the new sub aspect name already exists + const existingSubAspect = await db + .select() + .from(subAspects) + .where( + eq(subAspects.aspectId, subAspectData.aspectId)); + + if (existingSubAspect.length > 0) { + throw forbidden({ message: "Name Sub Aspect already exists" }); + } + + if (!existingSubAspect[0]) + throw notFound({ + message: "The sub aspect is not found", + }); + + await db + .update(subAspects) + .set({ + ...subAspectData, + updatedAt: new Date(), + }) + .where(eq(subAspects.id, subAspectId)); + + return c.json({ + message: "Sub aspect updated successfully", + }); + }) + + // Delete sub aspect + .delete( + "/subAspect/:id", + checkPermission("managementAspect.delete"), + async (c) => { + const subAspectId = c.req.param("id"); + + const subAspect = await db + .select() + .from(subAspects) + .where(eq(subAspects.id, subAspectId)); + + if (!subAspect[0]) + throw notFound({ + message: "The sub aspect is not found", + }); + + await db + .update(subAspects) + .set({ + deletedAt: new Date(), + }) + .where(eq(subAspects.id, subAspectId)); + // await db.delete(subAspects).where(eq(subAspects.id, subAspectId)); + + return c.json({ + message: "Sub aspect deleted successfully", + }); + } + ); + +export default managementAspectRoute; diff --git a/apps/backend/src/routes/register/route.ts b/apps/backend/src/routes/register/route.ts new file mode 100644 index 0000000..4be7398 --- /dev/null +++ b/apps/backend/src/routes/register/route.ts @@ -0,0 +1,131 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import db from "../../drizzle"; +import { respondents } from "../../drizzle/schema/respondents"; +import { users } from "../../drizzle/schema/users"; +import { rolesSchema } from "../../drizzle/schema/roles"; +import { rolesToUsers } from "../../drizzle/schema/rolesToUsers"; +import { hashPassword } from "../../utils/passwordUtils"; +import requestValidator from "../../utils/requestValidator"; +import authInfo from "../../middlewares/authInfo"; +import { or, eq } from "drizzle-orm"; +import { z } from "zod"; +import HonoEnv from "../../types/HonoEnv"; +import { notFound } from "../../errors/DashboardError"; + +const registerFormSchema = z.object({ + name: z.string().min(1).max(255), + username: z.string().min(1).max(255), + email: z.string().email(), + password: z.string().min(6), + companyName: z.string().min(1).max(255), + position: z.string().min(1).max(255), + workExperience: z.string().min(1).max(255), + address: z.string().min(1), + phoneNumber: z.string().min(1).max(13), + isEnabled: z.string().default("false"), +}); + +const respondentsRoute = new Hono() + .use(authInfo) + //post user + .post("/", requestValidator("json", registerFormSchema), async (c) => { + const formData = c.req.valid("json"); + + // Check if the provided email or username is already exists in database + const conditions = []; + if (formData.email) { + conditions.push(eq(users.email, formData.email)); + } + conditions.push(eq(users.username, formData.username)); + + const existingUser = await db + .select() + .from(users) + .where( + or( + eq(users.email, formData.email), + eq(users.username, formData.username) + ) + ); + + const existingRespondent = await db + .select() + .from(respondents) + .where(eq(respondents.phoneNumber, formData.phoneNumber)); + + if (existingUser.length > 0) { + throw new HTTPException(400, { + message: "Email or username has been registered", + }); + } + + if (existingRespondent.length > 0) { + throw new HTTPException(400, { + message: "Phone number has been registered", + }); + } + + // Hash the password + const hashedPassword = await hashPassword(formData.password); + + // Start a transaction + const result = await db.transaction(async (trx) => { + // Create user + const [newUser] = await trx + .insert(users) + .values({ + name: formData.name, + username: formData.username, + email: formData.email, + password: hashedPassword, + isEnabled: formData.isEnabled?.toLowerCase() === "true" || true, + }) + .returning() + .catch(() => { + throw new HTTPException(500, { message: "Error creating user" }); + }); + + // Create respondent + await trx + .insert(respondents) + .values({ + companyName: formData.companyName, + position: formData.position, + workExperience: formData.workExperience, + address: formData.address, + phoneNumber: formData.phoneNumber, + userId: newUser.id, + }) + .catch(() => { + throw new HTTPException(500, { + message: "Error creating respondent", + }); + }); + + // Automatically assign "user" role to the new user + const [role] = await trx + .select() + .from(rolesSchema) + .where(eq(rolesSchema.code, "user")) + .limit(1); + + if (!role) throw notFound(); + + await trx.insert(rolesToUsers).values({ + userId: newUser.id, + roleId: role.id, + }); + + return newUser; + }); + + return c.json( + { + message: "User created successfully", + }, + 201 + ); + }); + +export default respondentsRoute;