import { and, eq, ilike, isNull, or, sql, inArray } from "drizzle-orm"; import { Hono } from "hono"; import { questions } from "../../drizzle/schema/questions"; 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(), }); // Schema for creating and updating subAspects export const subAspectFormSchema = z.object({ id: z.string(), name: z.string().min(1).max(50), aspectId: z.string().optional(), }); export const aspectUpdateSchema = z.object({ name: z.string(), subAspects: z.array(subAspectFormSchema), }); 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(DISTINCT ${aspects.id}) FROM ${aspects})` : sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; const aspectIdsQuery = await db .select({ id: aspects.id, }) .from(aspects) .where( and( includeTrashed ? undefined : isNull(aspects.deletedAt), q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined ) ) .offset(page * limit) .limit(limit); const aspectIds = aspectIdsQuery.map(a => a.id); if (aspectIds.length === 0) { return c.json({ data: [], _metadata: { currentPage: page, totalPages: 0, totalItems: 0, perPage: limit, }, }); } // Main query to get aspects, sub-aspects, and number of questions const result = await db .select({ id: aspects.id, name: aspects.name, createdAt: aspects.createdAt, updatedAt: aspects.updatedAt, ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), subAspectId: subAspects.id, subAspectName: subAspects.name, // Increase the number of questions related to sub aspects questionCount: sql`( SELECT count(*) FROM ${questions} WHERE ${questions.subAspectId} = ${subAspects.id} )`.as('questionCount'), fullCount: totalCountQuery, }) .from(aspects) .leftJoin(subAspects, eq(subAspects.aspectId, aspects.id)) .where(inArray(aspects.id, aspectIds)); // Grouping sub aspects by aspect ID const groupedResult = result.reduce((acc, curr) => { const aspectId = curr.id; if (!acc[aspectId]) { acc[aspectId] = { id: curr.id, name: curr.name, createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null, updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null, subAspects: curr.subAspectName ? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }] : [], }; } else { if (curr.subAspectName) { const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId); if (!exists) { acc[aspectId].subAspects.push({ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount, }); } } } return acc; }, {} as Record); const groupedArray = Object.values(groupedResult); return c.json({ data: groupedArray, _metadata: { currentPage: page, totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit), totalItems: Number(result[0]?.fullCount) ?? 0, perPage: limit, }, }); } ) // Get 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, questionCount: sql`COUNT(${questions.id})`.as("questionCount"), }, }) .from(aspects) .leftJoin(subAspects, eq(aspects.id, subAspects.aspectId)) .leftJoin(questions, eq(subAspects.id, questions.subAspectId)) .where(and(eq(aspects.id, aspectId), !includeTrashed ? isNull(aspects.deletedAt) : undefined)) .groupBy(aspects.id, aspects.name, aspects.createdAt, aspects.updatedAt, subAspects.id, subAspects.name); if (!queryResult.length) throw notFound({ message: "The aspect does not exist", }); const subAspectsList = queryResult.reduce((prev, curr) => { if (!curr.subAspect) return prev; prev.set(curr.subAspect.id, { name: curr.subAspect.name, questionCount: Number(curr.subAspect.questionCount), }); return prev; }, new Map()); const aspectData = { ...queryResult[0], subAspect: undefined, subAspects: Array.from(subAspectsList, ([id, { name, questionCount }]) => ({ id, name, questionCount })), }; return c.json(aspectData); } ) // Create aspect .post("/", checkPermission("managementAspect.create"), requestValidator("json", aspectFormSchema), async (c) => { const aspectData = c.req.valid("json"); // Check if aspect name already exists and is not deleted const existingAspect = await db .select() .from(aspects) .where( and( eq(aspects.name, aspectData.name), isNull(aspects.deletedAt) ) ); if (existingAspect.length > 0) { // Return an error if the aspect name already exists return c.json( { message: "Aspect name already exists" }, 400 // Bad Request ); } // If it doesn't exist, create a new aspect const aspect = await db .insert(aspects) .values({ name: aspectData.name, }) .returning(); const aspectId = aspect[0].id; // If there is sub aspect data, parse and insert into the database. if (aspectData.subAspects) { const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; // Insert new sub aspects into the database without checking for sub aspect duplication if (subAspectsArray.length) { await db.insert(subAspects).values( subAspectsArray.map((subAspect) => ({ aspectId, name: subAspect, })) ); } } return c.json( { message: "Aspect and sub aspects 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"); // Check if 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 notFound({ message: "Aspect name already exists", }); } // Check if the aspect in question exists const aspect = await db .select() .from(aspects) .where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt))); if (!aspect[0]) throw notFound(); // Update aspect name await db .update(aspects) .set({ name: aspectData.name, updatedAt: new Date(), }) .where(eq(aspects.id, aspectId)); // Get new sub aspect data from request const newSubAspects = aspectData.subAspects || []; // Take the existing sub aspects const currentSubAspects = await db .select({ id: subAspects.id, name: subAspects.name }) .from(subAspects) .where(eq(subAspects.aspectId, aspectId)); const currentSubAspectMap = new Map(currentSubAspects.map(sub => [sub.id, sub.name])); // Sub aspects to be removed const subAspectsToDelete = currentSubAspects .filter(sub => !newSubAspects.some(newSub => newSub.id === sub.id)) .map(sub => sub.id); // Delete sub aspects that do not exist in the new data if (subAspectsToDelete.length) { await db .delete(subAspects) .where( and( eq(subAspects.aspectId, aspectId), inArray(subAspects.id, subAspectsToDelete) ) ); } // Update or add new sub aspects for (const subAspect of newSubAspects) { const existingSubAspect = currentSubAspectMap.has(subAspect.id); if (existingSubAspect) { // Update if sub aspect already exists await db .update(subAspects) .set({ name: subAspect.name, updatedAt: new Date(), }) .where( and( eq(subAspects.id, subAspect.id), eq(subAspects.aspectId, aspectId) ) ); } else { // Add if new sub aspect await db .insert(subAspects) .values({ id: subAspect.id, aspectId, name: subAspect.name, createdAt: new Date(), }); } } return c.json({ message: "Aspect and sub aspects updated successfully", }); } ) // Delete aspect .delete( "/:id", checkPermission("managementAspect.delete"), async (c) => { const aspectId = c.req.param("id"); // Check if aspect exists before deleting 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", }); // Update deletedAt column on aspect (soft delete) await db .update(aspects) .set({ deletedAt: new Date(), }) .where(eq(aspects.id, aspectId)); // Soft delete related sub aspects (update deletedAt on the sub-aspect) await db .update(subAspects) .set({ deletedAt: new Date(), }) .where(eq(subAspects.aspectId, aspectId)); return c.json({ message: "Aspect and sub aspects soft deleted successfully", }); } ) // Undo delete .patch( "/restore/:id", checkPermission("managementAspect.restore"), async (c) => { const aspectId = c.req.param("id"); // Check if the desired aspects are present const aspect = (await db.select().from(aspects).where(eq(aspects.id, aspectId)))[0]; if (!aspect) { throw notFound({ message: "The aspect is not found", }); } // Make sure the aspect has been deleted (there is deletedAt) if (!aspect.deletedAt) { throw notFound({ message: "The aspect is not deleted", }); } // Restore aspects (remove deletedAt mark) await db.update(aspects).set({ deletedAt: null }).where(eq(aspects.id, aspectId)); // Restore all related sub aspects that have also been deleted (if any) await db.update(subAspects).set({ deletedAt: null }).where(eq(subAspects.aspectId, aspectId)); return c.json({ message: "Aspect and sub aspects restored successfully", }); } ) export default managementAspectRoute;