import { Body, Controller, Delete, Get, Put, Path, Post, Request, Route, Security, Tags, Query, } from "tsoa"; import { Prisma, Status } from "@prisma/client"; import prisma from "../db"; import minio, { presignedGetObjectIfExist } from "../services/minio"; import { RequestWithUser } from "../interfaces/user"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MINIO_BUCKET = process.env.MINIO_BUCKET; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "branch_manager", "head_of_account", "account", ]; type ProductCreate = { status?: Status; code: | "DOE" | "IMM" | "TM" | "HP" | "MOUC" | "MOUL" | "AC" | "doe" | "imm" | "tm" | "hp" | "mouc" | "moul" | "ac"; name: string; detail: string; process: number; price: number; agentPrice: number; serviceCharge: number; vatIncluded?: boolean; expenseType?: string; productGroupId: string; remark?: string; registeredBranchId?: string; }; type ProductUpdate = { status?: "ACTIVE" | "INACTIVE"; name?: string; detail?: string; process?: number; price?: number; agentPrice?: number; serviceCharge?: number; remark?: string; vatIncluded?: boolean; expenseType?: string; productGroupId?: string; registeredBranchId?: string; }; function imageLocation(id: string) { return `product/${id}/image`; } function globalAllow(roles?: string[]) { return ["system", "head_of_admin", "admin", "branch_manager", "head_of_account"].some((v) => roles?.includes(v), ); } @Route("api/v1/product") @Tags("Product") export class ProductController extends Controller { @Get("stats") async getProductStats(@Query() productGroupId?: string) { return await prisma.product.count({ where: { productGroupId } }); } @Get() @Security("keycloak") async getProduct( @Query() status?: Status, @Query() productGroupId?: string, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, @Query() registeredBranchId?: string, ) { const filterStatus = (val?: Status) => { if (!val) return {}; return val !== Status.CREATED && val !== Status.ACTIVE ? { status: val } : { OR: [{ status: Status.CREATED }, { status: Status.ACTIVE }] }; }; const where = { OR: [ { name: { contains: query }, productGroupId, ...filterStatus(status) }, { detail: { contains: query }, productGroupId, ...filterStatus(status) }, ], AND: registeredBranchId ? { OR: [{ registeredBranchId: registeredBranchId }, { registeredBranchId: null }], } : undefined, } satisfies Prisma.ProductWhereInput; const [result, total] = await prisma.$transaction([ prisma.product.findMany({ include: { createdBy: true, updatedBy: true, }, orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.product.count({ where }), ]); return { result: await Promise.all( result.map(async (v) => ({ ...v, imageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(v.id), 12 * 60 * 60, ), })), ), page, pageSize, total, }; } @Get("{productId}") @Security("keycloak") async getProductById(@Path() productId: string) { const record = await prisma.product.findFirst({ include: { createdBy: true, updatedBy: true, }, where: { id: productId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Product cannot be found.", "productNotFound"); } return Object.assign(record, { imageUrl: await presignedGetObjectIfExist(MINIO_BUCKET, imageLocation(record.id), 60 * 60), }); } @Get("{productId}/image") async getProductImageById(@Request() req: RequestWithUser, @Path() productId: string) { const url = await presignedGetObjectIfExist(MINIO_BUCKET, imageLocation(productId), 60 * 60); if (!url) { throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound"); } return req.res?.redirect(url); } @Post() @Security("keycloak", MANAGE_ROLES) async createProduct(@Request() req: RequestWithUser, @Body() body: ProductCreate) { const [productGroup, branch] = await prisma.$transaction([ prisma.productGroup.findFirst({ include: { createdBy: true, updatedBy: true, }, where: { id: body.productGroupId }, }), prisma.branch.findFirst({ include: { user: { where: { userId: req.user.sub } } }, where: { id: body.registeredBranchId }, }), ]); if (!globalAllow(req.user.roles) && !branch?.user.find((v) => v.userId === req.user.sub)) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } if (!productGroup) { throw new HttpError( HttpStatus.BAD_REQUEST, "Product Group cannot be found.", "relationProductGroupNotFound", ); } if (body.registeredBranchId && !branch) { throw new HttpError( HttpStatus.BAD_REQUEST, "Branch cannot be found.", "relationBranchNotFound", ); } const record = await prisma.$transaction( async (tx) => { const last = await tx.runningNo.upsert({ where: { key: `PRODUCT_${body.code.toLocaleUpperCase()}`, }, create: { key: `PRODUCT_${body.code.toLocaleUpperCase()}`, value: 1, }, update: { value: { increment: 1 } }, }); return await prisma.product.create({ include: { createdBy: true, updatedBy: true, }, data: { ...body, statusOrder: +(body.status === "INACTIVE"), code: `${body.code.toLocaleUpperCase()}${last.value.toString().padStart(3, "0")}`, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, }, ); if (productGroup.status === "CREATED") { await prisma.productGroup.update({ include: { createdBy: true, updatedBy: true, }, where: { id: body.productGroupId }, data: { status: Status.ACTIVE }, }); } this.setStatus(HttpStatus.CREATED); return Object.assign(record, { imageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), imageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Put("{productId}") @Security("keycloak", MANAGE_ROLES) async editProduct( @Request() req: RequestWithUser, @Body() body: ProductUpdate, @Path() productId: string, ) { const [product, productGroup, branch] = await prisma.$transaction([ prisma.product.findUnique({ include: { registeredBranch: { where: { user: { some: { userId: req.user.sub } }, }, }, }, where: { id: productId }, }), prisma.productGroup.findFirst({ include: { createdBy: true, updatedBy: true, }, where: { id: body.productGroupId }, }), prisma.branch.findFirst({ where: { id: body.registeredBranchId } }), ]); if (!product) { throw new HttpError(HttpStatus.NOT_FOUND, "Product cannot be found.", "productNotFound"); } if (!globalAllow(req.user.roles) && !product.registeredBranch) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } if (!productGroup) { throw new HttpError( HttpStatus.BAD_REQUEST, "Product Group cannot be found.", "relationProductGroupNotFound", ); } if (body.registeredBranchId && !branch) { throw new HttpError( HttpStatus.BAD_REQUEST, "Branch cannot be found.", "relationBranchNotFound", ); } const record = await prisma.product.update({ include: { createdBy: true, updatedBy: true, }, data: { ...body, statusOrder: +(body.status === "INACTIVE"), updatedByUserId: req.user.sub }, where: { id: productId }, }); if (productGroup.status === "CREATED") { await prisma.productGroup.updateMany({ where: { id: body.productGroupId, status: Status.CREATED }, data: { status: Status.ACTIVE }, }); } return Object.assign(record, { imageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), imageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Delete("{productId}") @Security("keycloak", MANAGE_ROLES) async deleteProduct(@Request() req: RequestWithUser, @Path() productId: string) { const record = await prisma.product.findFirst({ include: { registeredBranch: { where: { user: { some: { userId: req.user.sub } }, }, }, }, where: { id: productId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Product cannot be found.", "productNotFound"); } if (!globalAllow(req.user.roles) && !record.registeredBranch) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } if (record.status !== Status.CREATED) { throw new HttpError(HttpStatus.FORBIDDEN, "Product is in used.", "productInUsed"); } return await prisma.product.delete({ include: { createdBy: true, updatedBy: true, }, where: { id: productId }, }); } }