import { Body, Controller, Delete, Get, Put, Path, Post, Request, Route, Security, Tags, Query, } from "tsoa"; import { Prisma, Product, 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"; import { queryOrNot } from "../utils/relation"; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "head_of_accountant", "accountant", "head_of_sale", ]; function globalAllow(user: RequestWithUser["user"]) { const allowList = ["system", "head_of_admin", "head_of_accountant", "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: string; name: string; detail: string; process: number; price: number; agentPrice: number; serviceCharge: number; vatIncluded?: boolean; calcVat?: boolean; expenseType?: string; selectedImage?: string; shared?: boolean; productGroupId: string; remark?: string; document?: string[]; }; type ProductUpdate = { status?: "ACTIVE" | "INACTIVE"; name?: string; detail?: string; process?: number; price?: number; agentPrice?: number; serviceCharge?: number; remark?: string; vatIncluded?: boolean; calcVat?: boolean; expenseType?: string; selectedImage?: string; shared?: boolean; productGroupId?: string; document?: 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) }, }, }, { productGroup: { shared: true, registeredBranch: { OR: permissionCondCompany(req.user) }, }, }, ], }, }); } @Get() @Security("keycloak") async getProduct( @Request() req: RequestWithUser, @Query() status?: Status, @Query() shared?: boolean, @Query() productGroupId?: string, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, @Query() orderField?: keyof Product, @Query() orderBy?: "asc" | "desc", @Query() activeOnly?: boolean, ) { const where = { OR: queryOrNot(query, [ { name: { contains: query } }, { detail: { contains: query } }, { code: { contains: query, mode: "insensitive" } }, ]), AND: { ...filterStatus(activeOnly ? Status.ACTIVE : status), productGroup: { status: activeOnly ? { not: Status.INACTIVE } : undefined, registeredBranch: activeOnly ? { OR: [ { headOffice: { status: { not: Status.INACTIVE } } }, { headOffice: null, status: { not: Status.INACTIVE } }, ], } : undefined, }, OR: [ ...(productGroupId ? [ shared ? { OR: [ { productGroupId }, { shared: true, productGroup: { registeredBranch: { OR: permissionCondCompany(req.user, { activeOnly }), }, }, }, { productGroup: { shared: true, registeredBranch: { OR: permissionCondCompany(req.user, { activeOnly }), }, }, }, ], } : { productGroupId }, ] : []), ...(isSystem(req.user) ? [] : [ { productGroup: { id: productGroupId, registeredBranch: { OR: permissionCondCompany(req.user, { activeOnly }) }, }, }, ]), ], }, } satisfies Prisma.ProductWhereInput; const [result, total] = await prisma.$transaction([ prisma.product.findMany({ include: { document: true, createdBy: true, updatedBy: true, }, orderBy: [ { statusOrder: "asc" }, ...((orderField && orderBy && [{ [orderField]: orderBy }]) || []), { createdAt: "asc" }, ], where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.product.count({ where }), ]); return { result: result.map((v) => ({ ...v, document: v.document.map((doc) => doc.name) })), page, pageSize, total, }; } @Get("{productId}") @Security("keycloak") async getProductById(@Path() productId: string) { const record = await prisma.product.findFirst({ include: { document: true, createdBy: true, updatedBy: true, }, where: { id: productId }, }); if (!record) throw notFoundError("Product"); return { ...record, document: record.document.map((doc) => doc.name) }; } @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, document: body.document ? { createMany: { data: body.document.map((v) => ({ name: v })) }, } : undefined, 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, document: body.document ? { deleteMany: {}, createMany: { data: body.document.map((v) => ({ name: v })) }, } : undefined, 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)); } }