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 { RequestWithUser } from "../interfaces/user"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; import { isSystem } from "../utils/keycloak"; import { filterStatus } from "../services/prisma"; import { deleteFile, fileLocation, getFile, listFile, setFile } from "../utils/minio"; import { isUsedError, notFoundError, relationError } from "../utils/error"; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "head_of_account", "account", "head_of_sale", ]; function globalAllow(user: RequestWithUser["user"]) { const allowList = ["system", "head_of_admin", "head_of_account", "head_of_sale"]; return allowList.some((v) => user.roles?.includes(v)); } const permissionCondCompany = createPermCondition((_) => true); const permissionCond = createPermCondition(globalAllow); const permissionCheck = createPermCheck(globalAllow); 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; selectedImage?: string; shared?: boolean; productGroupId: string; remark?: string; }; type ProductUpdate = { status?: "ACTIVE" | "INACTIVE"; name?: string; detail?: string; process?: number; price?: number; agentPrice?: number; serviceCharge?: number; remark?: string; vatIncluded?: boolean; expenseType?: string; selectedImage?: string; shared?: boolean; productGroupId?: string; }; @Route("api/v1/product") @Tags("Product") export class ProductController extends Controller { @Get("stats") @Security("keycloak") async getProductStats(@Request() req: RequestWithUser, @Query() productGroupId?: string) { return await prisma.product.count({ where: { productGroupId, OR: isSystem(req.user) ? undefined : [ { productGroup: { registeredBranch: { OR: permissionCond(req.user) }, }, }, { shared: true, productGroup: { registeredBranch: { OR: permissionCondCompany(req.user) }, }, }, ], }, }); } @Get() @Security("keycloak") async getProduct( @Request() req: RequestWithUser, @Query() status?: Status, @Query() productGroupId?: string, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { const where = { OR: query ? [{ name: { contains: query } }, { detail: { contains: query } }] : undefined, AND: { ...filterStatus(status), productGroupId, OR: isSystem(req.user) ? undefined : [ { productGroup: { registeredBranch: { OR: permissionCond(req.user) }, }, }, { shared: true, productGroup: { registeredBranch: { OR: permissionCondCompany(req.user) }, }, }, ], }, } 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, 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 notFoundError("Product"); return record; } @Post() @Security("keycloak", MANAGE_ROLES) async createProduct(@Request() req: RequestWithUser, @Body() body: ProductCreate) { const [productGroup, productSameName] = await prisma.$transaction([ prisma.productGroup.findFirst({ include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, createdBy: true, updatedBy: true, }, where: { id: body.productGroupId }, }), prisma.product.findMany({ where: { productGroup: { registeredBranch: { OR: permissionCondCompany(req.user), }, }, name: body.name, }, }), ]); if (!productGroup) throw relationError("Product Group"); if (productSameName.some((v) => v.code.slice(0, -3) === body.code.toUpperCase())) { throw new HttpError( HttpStatus.BAD_REQUEST, "Product with the same name and code already exists", "productNameExists", ); } await permissionCheck(req.user, productGroup.registeredBranch); const record = await prisma.$transaction( async (tx) => { const branch = productGroup.registeredBranch; const company = (branch.headOffice || branch).code; const last = await tx.runningNo.upsert({ where: { key: `PRODUCT_${company}_${body.code.toLocaleUpperCase()}`, }, create: { key: `PRODUCT_${company}_${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 record; } @Put("{productId}") @Security("keycloak", MANAGE_ROLES) async editProduct( @Request() req: RequestWithUser, @Body() body: ProductUpdate, @Path() productId: string, ) { const [product, productGroup] = await prisma.$transaction([ prisma.product.findUnique({ include: { productGroup: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, where: { id: productId }, }), prisma.productGroup.findFirst({ include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, createdBy: true, updatedBy: true, }, where: { id: body.productGroupId }, }), ]); if (!product) throw notFoundError("Product"); if (!!body.productGroupId && !productGroup) throw relationError("Product Group"); await permissionCheck(req.user, product.productGroup.registeredBranch); if (body.productGroupId && productGroup) { await permissionCheck(req.user, productGroup.registeredBranch); } 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 record; } @Delete("{productId}") @Security("keycloak", MANAGE_ROLES) async deleteProduct(@Request() req: RequestWithUser, @Path() productId: string) { const record = await prisma.product.findFirst({ include: { productGroup: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, where: { id: productId }, }); if (!record) throw notFoundError("Product"); if (record.status !== Status.CREATED) throw isUsedError("Product"); return await prisma.product.delete({ include: { createdBy: true, updatedBy: true, }, where: { id: productId }, }); } } @Route("api/v1/product/{productId}") @Tags("Product") export class ProductFileController extends Controller { async checkPermission(user: RequestWithUser["user"], id: string) { const data = await prisma.product.findUnique({ include: { productGroup: { include: { registeredBranch: { include: branchRelationPermInclude(user), }, }, }, }, where: { id }, }); if (!data) throw notFoundError("Product"); await permissionCheck(user, data.productGroup.registeredBranch); } @Get("image") @Security("keycloak") async listImage(@Request() req: RequestWithUser, @Path() productId: string) { await this.checkPermission(req.user, productId); return await listFile(fileLocation.product.img(productId)); } @Get("image/{name}") async getImage(@Request() req: RequestWithUser, @Path() productId: string, @Path() name: string) { return req.res?.redirect(await getFile(fileLocation.product.img(productId, name))); } @Put("image/{name}") @Security("keycloak") async putImage(@Request() req: RequestWithUser, @Path() productId: string, @Path() name: string) { if (!req.headers["content-type"]?.startsWith("image/")) { throw new HttpError(HttpStatus.BAD_REQUEST, "Not a valid image.", "notValidImage"); } await this.checkPermission(req.user, productId); return req.res?.redirect(await setFile(fileLocation.product.img(productId, name))); } @Delete("image/{name}") @Security("keycloak") async delImage(@Request() req: RequestWithUser, @Path() productId: string, @Path() name: string) { await this.checkPermission(req.user, productId); return await deleteFile(fileLocation.product.img(productId, name)); } }