import { CustomerType, Prisma, Status } from "@prisma/client"; import { Body, Controller, Delete, Get, Head, Path, Post, Put, Query, Request, Route, Security, Tags, } from "tsoa"; import { RequestWithUser } from "../interfaces/user"; import prisma from "../db"; import HttpStatus from "../interfaces/http-status"; import HttpError from "../interfaces/http-error"; import { isSystem } from "../utils/keycloak"; import { branchActiveOnlyCond, branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; import { filterStatus } from "../services/prisma"; import { deleteFile, deleteFolder, fileLocation, getFile, getPresigned, listFile, setFile, } from "../utils/minio"; import { isUsedError, notFoundError, relationError } from "../utils/error"; import { connectOrNot, queryOrNot, whereDateQuery } from "../utils/relation"; import { json2csv } from "json-2-csv"; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "executive", "accountant", "branch_admin", "branch_manager", "branch_accountant", "head_of_sale", "sale", ]; function globalAllow(user: RequestWithUser["user"]) { const listAllowed = MANAGE_ROLES; return user.roles?.some((v) => listAllowed.includes(v)) || false; } const permissionCondCompany = createPermCondition((_) => true); const permissionCond = createPermCondition(globalAllow); const permissionCheck = createPermCheck(globalAllow); export type CustomerCreate = { registeredBranchId: string; customerType: CustomerType; status?: Status; selectedImage?: string; branch: { // NOTE: About (Natural Person) citizenId?: string; namePrefix?: string; firstName?: string; firstNameEN?: string; lastName?: string; lastNameEN?: string; gender?: string; birthDate?: Date; // NOTE: About (Legal Entity) legalPersonNo?: string; registerName?: string; registerNameEN?: string; registerDate?: Date; authorizedCapital?: string; authorizedName?: string; authorizedNameEN?: string; telephoneNo: string; status?: Status; homeCode: string; employmentOffice: string; employmentOfficeEN: string; address: string; addressEN: string; soi?: string | null; soiEN?: string | null; moo?: string | null; mooEN?: string | null; street?: string | null; streetEN?: string | null; email: string; contactTel: string; officeTel: string; contactName: string; agentUserId?: string; businessTypeId?: string | null; jobPosition: string; jobDescription: string; payDate: string; payDateEN: string; wageRate: number; wageRateText: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; }[]; }; export type CustomerUpdate = { registeredBranchId?: string; status?: "ACTIVE" | "INACTIVE"; customerType?: CustomerType; selectedImage?: string; }; @Route("api/v1/customer") @Tags("Customer") export class CustomerController extends Controller { @Get("type-stats") @Security("keycloak") async stat(@Request() req: RequestWithUser) { const list = await prisma.customer.groupBy({ by: "customerType", _count: true, where: { registeredBranch: isSystem(req.user) ? undefined : { OR: permissionCond(req.user) }, }, }); return list.reduce>( (a, c) => { a[c.customerType] = c._count; return a; }, { CORP: 0, PERS: 0, }, ); } @Get() @Security("keycloak") async list( @Request() req: RequestWithUser, @Query() customerType?: CustomerType, @Query() query: string = "", @Query() status?: Status, @Query() page: number = 1, @Query() pageSize: number = 30, @Query() includeBranch: boolean = false, @Query() company: boolean = false, @Query() activeBranchOnly?: boolean, @Query() startDate?: Date, @Query() endDate?: Date, @Query() businessTypeId?: string, @Query() provinceId?: string, @Query() districtId?: string, @Query() subDistrictId?: string, ) { const where = { OR: queryOrNot(query, [ { branch: { some: { namePrefix: { contains: query, mode: "insensitive" } } } }, { branch: { some: { registerName: { contains: query, mode: "insensitive" } } } }, { branch: { some: { registerNameEN: { contains: query, mode: "insensitive" } } } }, { branch: { some: { firstName: { contains: query, mode: "insensitive" } } } }, { branch: { some: { firstNameEN: { contains: query, mode: "insensitive" } } } }, { branch: { some: { lastName: { contains: query, mode: "insensitive" } } } }, { branch: { some: { lastNameEN: { contains: query, mode: "insensitive" } } } }, ]), AND: { customerType, ...filterStatus(status), registeredBranch: isSystem(req.user) ? branchActiveOnlyCond(activeBranchOnly) : { OR: company ? permissionCondCompany(req.user, { activeOnly: activeBranchOnly }) : permissionCond(req.user, { activeOnly: activeBranchOnly }), }, }, branch: { some: { AND: [ businessTypeId ? { OR: [{ businessType: { id: businessTypeId } }], } : {}, provinceId ? { OR: [{ province: { id: provinceId } }], } : {}, districtId ? { OR: [{ district: { id: districtId } }], } : {}, subDistrictId ? { OR: [{ subDistrict: { id: subDistrictId } }], } : {}, ], }, }, ...whereDateQuery(startDate, endDate), } satisfies Prisma.CustomerWhereInput; const [result, total] = await prisma.$transaction([ prisma.customer.findMany({ include: { _count: true, branch: includeBranch ? { include: { businessType: true, province: true, district: true, subDistrict: true, }, omit: { otpCode: true, otpExpires: true, userId: true, }, orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], } : { include: { province: true, district: true, subDistrict: true, }, omit: { otpCode: true, otpExpires: true, userId: true, }, take: 1, orderBy: { createdAt: "asc" }, }, createdBy: true, updatedBy: true, // businessType:true }, orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.customer.count({ where }), ]); return { result, page, pageSize, total }; } @Get("{customerId}") @Security("keycloak") async getById(@Path() customerId: string) { const [record, countEmployee] = await prisma.$transaction([ prisma.customer.findFirst({ include: { branch: { include: { province: true, district: true, subDistrict: true, }, omit: { otpCode: true, otpExpires: true, userId: true, }, orderBy: { createdAt: "asc" }, }, createdBy: true, updatedBy: true, }, where: { id: customerId }, }), prisma.employee.count({ where: { customerBranch: { customerId } } }), ]); if (!record) throw notFoundError("Customer"); return Object.assign(record, { _count: { employee: countEmployee } }); } @Post() @Security("keycloak", MANAGE_ROLES) async create(@Request() req: RequestWithUser, @Body() body: CustomerCreate) { const [registeredBranch] = await prisma.$transaction([ prisma.branch.findFirst({ where: { id: body.registeredBranchId }, include: branchRelationPermInclude(req.user), }), ]); await permissionCheck(req.user, registeredBranch); const record = await prisma.$transaction( async (tx) => { await tx.branch.updateMany({ where: { id: body.registeredBranchId, status: "CREATED", }, data: { status: "INACTIVE", statusOrder: 1, }, }); const { branch, ...rest } = body; const company = (registeredBranch?.headOffice || registeredBranch)?.code; const headoffice = branch[0]; if (!headoffice) { throw new HttpError( HttpStatus.BAD_REQUEST, "Require at least one branch as headoffice", "requireOneMinBranch", ); } const runningKey = `CUSTOMER_BRANCH_${company}_${"citizenId" in headoffice ? headoffice.citizenId : headoffice.legalPersonNo}`; const last = await tx.runningNo.upsert({ where: { key: runningKey }, create: { key: runningKey, value: branch.length, }, update: { value: { increment: branch.length } }, }); return await tx.customer.create({ include: { branch: { include: { province: true, district: true, subDistrict: true, }, omit: { otpCode: true, otpExpires: true, userId: true, }, }, createdBy: true, updatedBy: true, }, data: { ...rest, branch: { create: branch.map((v, i) => ({ ...v, code: `${runningKey.replace(`CUSTOMER_BRANCH_${company}_`, "")}-${`${last.value - branch.length + i}`.padStart(2, "0")}`, codeCustomer: runningKey.replace(`CUSTOMER_BRANCH_${company}_`, ""), businessType: connectOrNot(v.businessTypeId), businessTypeId: undefined, agentUser: connectOrNot(v.agentUserId), agentUserId: undefined, province: connectOrNot(v.provinceId), provinceId: undefined, district: connectOrNot(v.districtId), districtId: undefined, subDistrict: connectOrNot(v.subDistrictId), subDistrictId: undefined, createdBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } }, })), }, statusOrder: +(body.status === "INACTIVE"), createdByUserId: req.user.sub, updatedByUserId: req.user.sub, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); this.setStatus(HttpStatus.CREATED); return record; } @Put("{customerId}") @Security("keycloak", MANAGE_ROLES) async editById( @Path() customerId: string, @Request() req: RequestWithUser, @Body() body: CustomerUpdate, ) { if (body.registeredBranchId === "") body.registeredBranchId = undefined; const customer = await prisma.customer.findUnique({ where: { id: customerId }, include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }); if (!customer) throw notFoundError("Branch"); const [branch] = await prisma.$transaction([ prisma.branch.findFirst({ where: { id: body.registeredBranchId }, include: branchRelationPermInclude(req.user), }), ]); if (!!body.registeredBranchId && !branch) throw relationError("Branch"); if (customer.registeredBranch) { await permissionCheck(req.user, customer.registeredBranch); } if (body.registeredBranchId !== undefined && branch) { await permissionCheck(req.user, branch); } if (!!body.registeredBranchId && !branch) throw relationError("Branch"); let companyBefore = (customer.registeredBranch.headOffice || customer.registeredBranch).code; let companyAfter = !!body.registeredBranchId && branch ? (branch.headOffice || branch).code : false; if (companyBefore && companyAfter && companyBefore !== companyAfter) { throw new HttpError( HttpStatus.BAD_REQUEST, "Cannot move between different headoffice", "crossCompanyNotPermit", ); } const record = await prisma.$transaction(async (tx) => { return await tx.customer.update({ include: { branch: { include: { province: true, district: true, subDistrict: true, }, omit: { otpCode: true, otpExpires: true, userId: true, }, }, createdBy: true, updatedBy: true, }, where: { id: customerId }, data: { ...body, statusOrder: +(body.status === "INACTIVE"), updatedByUserId: req.user.sub, }, }); }); return record; } @Delete("{customerId}") @Security("keycloak", MANAGE_ROLES) async deleteById(@Path() customerId: string, @Request() req: RequestWithUser) { const record = await prisma.customer.findFirst({ where: { id: customerId }, include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }); if (!record) throw notFoundError("Customer"); await permissionCheck(req.user, record.registeredBranch); if (record.status !== Status.CREATED) throw isUsedError("Customer"); await prisma.$transaction(async (tx) => { await deleteFolder(`customer/${customerId}`); const data = await tx.customer.delete({ include: { branch: { omit: { otpCode: true, otpExpires: true, userId: true, }, }, registeredBranch: { include: { headOffice: true, }, }, }, where: { id: customerId }, }); await tx.runningNo.deleteMany({ where: { key: { in: data.branch.map( (v) => `CUSTOMER_BRANCH_${(data.registeredBranch.headOffice || data.registeredBranch).code}_${v.code.slice(0, -3)}`, ), }, }, }); return data; }); } } @Route("api/v1/customer/{customerId}/image") @Tags("Customer") export class CustomerImageController extends Controller { private async checkPermission(user: RequestWithUser["user"], id: string) { const data = await prisma.customer.findUnique({ include: { registeredBranch: { include: branchRelationPermInclude(user), }, }, where: { id }, }); if (!data) throw notFoundError("Customer"); await permissionCheck(user, data.registeredBranch); } @Get() @Security("keycloak") async listImage(@Request() req: RequestWithUser, @Path() customerId: string) { await this.checkPermission(req.user, customerId); return await listFile(fileLocation.customer.img(customerId)); } @Get("{name}") async getImage( @Request() req: RequestWithUser, @Path() customerId: string, @Path() name: string, ) { return req.res?.redirect( await getFile(fileLocation.customer.img(customerId, name), 12 * 60 * 60), ); } @Head("{name}") async headImage( @Request() req: RequestWithUser, @Path() customerId: string, @Path() name: string, ) { return req.res?.redirect( await getPresigned("head", fileLocation.customer.img(customerId, name), 12 * 60 * 60), ); } @Put("{name}") @Security("keycloak") async putImage( @Request() req: RequestWithUser, @Path() customerId: string, @Path() name: string, ) { await this.checkPermission(req.user, customerId); return req.res?.redirect( await setFile(fileLocation.customer.img(customerId, name), 12 * 60 * 60), ); } @Delete("{name}") @Security("keycloak") async deleteImage( @Request() req: RequestWithUser, @Path() customerId: string, @Path() name: string, ) { await this.checkPermission(req.user, customerId); await deleteFile(fileLocation.customer.img(customerId, name)); } } @Route("api/v1/customer-export") @Tags("Customer") export class CustomerExportController extends CustomerController { @Get() @Security("keycloak") async exportCustomer( @Request() req: RequestWithUser, @Query() customerType?: CustomerType, @Query() query: string = "", @Query() status?: Status, @Query() page: number = 1, @Query() pageSize: number = 30, @Query() includeBranch: boolean = false, @Query() company: boolean = false, @Query() activeBranchOnly?: boolean, @Query() startDate?: Date, @Query() endDate?: Date, @Query() businessTypeId?: string, @Query() provinceId?: string, @Query() districtId?: string, @Query() subDistrictId?: string, ) { const ret = await this.list( req, customerType, query, status, page, pageSize, includeBranch, company, activeBranchOnly, startDate, endDate, businessTypeId, provinceId, districtId, subDistrictId, ); this.setHeader("Content-Type", "text/csv"); return json2csv( ret.result.map((v) => Object.assign(v, { branch: v.branch.at(0) ?? null })), { useDateIso8601Format: true, expandNestedObjects: true }, ); } }