import { Body, Controller, Delete, Get, Put, Path, Post, Query, Request, Route, Security, Tags, Head, } 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 { isSystem } from "../utils/keycloak"; import { branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; import { filterStatus } from "../services/prisma"; import { isUsedError, notFoundError, relationError } from "../utils/error"; import { deleteFile, deleteFolder, fileLocation, getFile, getPresigned, listFile, setFile, } from "../utils/minio"; 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 ServiceCreate = { code: string; name: string; detail: string; attributes?: { [key: string]: any; }; status?: Status; /** * @isInt */ installments?: number; workflowId?: string; work?: { name: string; product: { id: string; /** * @isInt */ installmentNo?: number; /** * @isInt */ stepCount?: number; attributes?: { [key: string]: any }; }[]; attributes?: { [key: string]: any }; }[]; shared?: boolean; selectedImage?: string; productGroupId: string; }; type ServiceUpdate = { name?: string; detail?: string; attributes?: { [key: string]: any; }; /** * @isInt */ installments?: number; status?: "ACTIVE" | "INACTIVE"; workflowId?: string | null; work?: { id?: string; name: string; product: { id: string; /** * @isInt */ installmentNo?: number; /** * @isInt */ stepCount?: number; attributes?: { [key: string]: any }; }[]; attributes?: { [key: string]: any }; }[]; shared?: boolean; selectedImage?: string; productGroupId?: string; }; @Route("api/v1/service") @Tags("Service") export class ServiceController extends Controller { @Get("stats") @Security("keycloak") async getServiceStats(@Request() req: RequestWithUser, @Query() productGroupId?: string) { return await prisma.service.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 getService( @Request() req: RequestWithUser, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, @Query() status?: Status, @Query() productGroupId?: string, @Query() fullDetail?: boolean, @Query() activeOnly?: boolean, @Query() shared?: boolean, ) { // NOTE: will be used to scope product within product group that is shared between branch but not company when select shared product if user is system const targetGroup = productGroupId && req.user.roles.includes("system") ? await prisma.productGroup.findFirst({ where: { id: productGroupId }, }) : undefined; if (targetGroup !== undefined && !targetGroup) throw notFoundError("Product Group"); const targetBranchId = targetGroup?.registeredBranchId; 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: { OR: permissionCondCompany(req.user, { activeOnly, targetBranchId }) }, }, OR: [ ...(productGroupId ? [ shared ? { OR: [ { productGroupId }, { shared: true, productGroup: { registeredBranch: { OR: permissionCondCompany(req.user, { activeOnly, targetBranchId }), }, }, }, { productGroup: { shared: true, registeredBranch: { OR: permissionCondCompany(req.user, { activeOnly, targetBranchId }), }, }, }, ], } : { productGroupId }, ] : []), ], }, } satisfies Prisma.ServiceWhereInput; const [result, total] = await prisma.$transaction([ prisma.service.findMany({ include: { work: fullDetail ? { orderBy: { order: "asc" }, include: { productOnWork: { include: { product: true }, orderBy: { order: "asc" }, }, }, } : true, createdBy: true, updatedBy: true, }, orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.service.count({ where }), ]); return { result, page, pageSize, total, }; } @Get("{serviceId}") @Security("keycloak") async getServiceById(@Path() serviceId: string) { const record = await prisma.service.findFirst({ include: { work: { orderBy: { order: "asc" }, include: { productOnWork: { include: { product: true }, orderBy: { order: "asc" }, }, }, }, createdBy: true, updatedBy: true, }, where: { id: serviceId }, }); if (!record) throw notFoundError("Service"); return record; } @Get("{serviceId}/work") @Security("keycloak") async getWorkOfService( @Path() serviceId: string, @Query() page: number = 1, @Query() pageSize: number = 30, ) { const where = { serviceId, } satisfies Prisma.WorkWhereInput; const [result, total] = await prisma.$transaction([ prisma.work.findMany({ include: { productOnWork: { include: { product: true, }, orderBy: { order: "asc" }, }, createdBy: true, updatedBy: true, }, where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.work.count({ where }), ]); return { result, page, pageSize, total }; } @Post() @Security("keycloak", MANAGE_ROLES) async createService(@Request() req: RequestWithUser, @Body() body: ServiceCreate) { const { work, productGroupId, ...payload } = body; if (!payload.workflowId) payload.workflowId = undefined; const [productGroup] = await prisma.$transaction([ prisma.productGroup.findFirst({ include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, createdBy: true, updatedBy: true, }, where: { id: body.productGroupId }, }), ]); if (!productGroup) throw relationError("Product Type"); await permissionCheck(req.user, productGroup.registeredBranch); if (body.installments && body.work) { for (let i = 0; i < body.installments; i++) { if (!body.work.some((w) => w.product.some((p) => p.installmentNo === i + 1))) { throw new HttpError( HttpStatus.BAD_REQUEST, "Installments validate failed.", "installmentsValidateFailed", ); } } } else { throw new HttpError( HttpStatus.BAD_REQUEST, "Installments validate failed.", "installmentsValidateFailed", ); } 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: `SERVICE_${company}_${body.code.toLocaleUpperCase()}`, }, create: { key: `SERVICE_${company}_${body.code.toLocaleUpperCase()}`, value: 1, }, update: { value: { increment: 1 } }, }); if (!payload.workflowId) payload.workflowId = undefined; return tx.service.create({ include: { work: { include: { productOnWork: { include: { product: true }, orderBy: { order: "asc" }, }, }, }, createdBy: true, updatedBy: true, }, data: { ...payload, productGroupId, statusOrder: +(body.status === "INACTIVE"), code: `${body.code.toLocaleUpperCase()}${last.value.toString().padStart(3, "0")}`, work: { create: (work || []).map((w, wIdx) => ({ name: w.name, order: wIdx + 1, attributes: w.attributes, productOnWork: { create: w.product.map((p, pIdx) => ({ productId: p.id, installmentNo: p.installmentNo, stepCount: p.stepCount, order: pIdx + 1, })), }, })), }, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); this.setStatus(HttpStatus.CREATED); return record; } @Put("{serviceId}") @Security("keycloak", MANAGE_ROLES) async editService( @Request() req: RequestWithUser, @Body() body: ServiceUpdate, @Path() serviceId: string, ) { const service = await prisma.service.findUnique({ where: { id: serviceId }, include: { productGroup: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, }); if (!service) throw notFoundError("Service"); const { work, productGroupId, ...payload } = body; if (!payload.workflowId && payload.workflowId !== undefined) payload.workflowId = undefined; const [productGroup] = await prisma.$transaction([ prisma.productGroup.findFirst({ include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, createdBy: true, updatedBy: true, }, where: { id: body.productGroupId }, }), ]); if (!service) throw notFoundError("Service"); if (!!body.productGroupId && !productGroup) throw relationError("Product Group"); await permissionCheck(req.user, service.productGroup.registeredBranch); if (!!body.productGroupId && productGroup) { await permissionCheck(req.user, productGroup.registeredBranch); } if (body.installments && body.work) { for (let i = 0; i < body.installments; i++) { if (!body.work.some((w) => w.product.some((p) => p.installmentNo === i + 1))) { throw new HttpError( HttpStatus.BAD_REQUEST, "Installments validate failed.", "installmentsValidateFailed", ); } } } const record = await prisma.$transaction(async (tx) => { if (payload.workflowId === "") payload.workflowId = undefined; return await tx.service.update({ include: { productGroup: true, createdBy: true, updatedBy: true, }, data: { ...payload, statusOrder: +(payload.status === "INACTIVE"), work: { deleteMany: work?.some((v) => !!v.id) ? { id: { notIn: work.flatMap((v) => (!!v.id ? v.id : [])) } } : {}, upsert: (work || []).map((w, wIdx) => ({ where: { id: w.id }, create: { name: w.name, order: wIdx + 1, attributes: w.attributes, productOnWork: { createMany: { data: w.product.map((p, pIdx) => ({ productId: p.id, installmentNo: p.installmentNo, stepCount: p.stepCount, order: pIdx + 1, })), skipDuplicates: true, }, }, }, update: { name: w.name, order: wIdx + 1, attributes: w.attributes, productOnWork: { deleteMany: {}, create: w.product.map((p, pIdx) => ({ productId: p.id, installmentNo: p.installmentNo, stepCount: p.stepCount, order: pIdx + 1, })), }, }, })), }, updatedByUserId: req.user.sub, }, where: { id: serviceId }, }); }); await prisma.notification.create({ data: { title: "แพคเกจมีการเปลี่ยนแปลง / Package Updated", detail: "รหัส / code : " + record.code, groupReceiver: { create: [{ name: "sale" }, { name: "head_of_sale" }], }, registeredBranchId: record.productGroup.registeredBranchId, }, }); return record; } @Delete("{serviceId}") @Security("keycloak", MANAGE_ROLES) async deleteService(@Request() req: RequestWithUser, @Path() serviceId: string) { const record = await prisma.service.findFirst({ include: { productGroup: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, where: { id: serviceId }, }); if (!record) throw notFoundError("Service"); await permissionCheck(req.user, record.productGroup.registeredBranch); if (record.status !== Status.CREATED) throw isUsedError("Service"); await deleteFolder(fileLocation.service.img(serviceId)); return await prisma.service.delete({ include: { createdBy: true, updatedBy: true, }, where: { id: serviceId }, }); } } @Route("api/v1/service/{serviceId}") @Tags("Service") export class ServiceFileController extends Controller { async checkPermission(user: RequestWithUser["user"], id: string) { const data = await prisma.service.findUnique({ include: { productGroup: { include: { registeredBranch: { include: branchRelationPermInclude(user), }, }, }, }, where: { id }, }); if (!data) throw notFoundError("Service"); await permissionCheck(user, data.productGroup.registeredBranch); } @Get("image") @Security("keycloak") async listImage(@Request() req: RequestWithUser, @Path() serviceId: string) { await this.checkPermission(req.user, serviceId); return await listFile(fileLocation.service.img(serviceId)); } @Get("image/{name}") async getImage(@Request() req: RequestWithUser, @Path() serviceId: string, @Path() name: string) { return req.res?.redirect(await getFile(fileLocation.service.img(serviceId, name))); } @Head("image/{name}") async headImage( @Request() req: RequestWithUser, @Path() serviceId: string, @Path() name: string, ) { return req.res?.redirect(await getPresigned("head", fileLocation.service.img(serviceId, name))); } @Put("image/{name}") @Security("keycloak") async putImage(@Request() req: RequestWithUser, @Path() serviceId: 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, serviceId); return req.res?.redirect(await setFile(fileLocation.service.img(serviceId, name))); } @Delete("image/{name}") @Security("keycloak") async delImage(@Request() req: RequestWithUser, @Path() serviceId: string, @Path() name: string) { await this.checkPermission(req.user, serviceId); return await deleteFile(fileLocation.service.img(serviceId, name)); } }