import { Prisma, Status } from "@prisma/client"; import { Body, Controller, Delete, Get, 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 minio from "../services/minio"; 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_admin", "branch_manager", "head_of_sale", "sale", ]; function imageLocation(id: string) { return `employee/profile-img-${id}`; } function attachmentLocation(customerId: string, branchId: string) { return `customer/${customerId}/branch/${branchId}`; } export type CustomerBranchCreate = ( | { // NOTE: About (Natural Person) citizenId: string; } | { // NOTE: About (Legal Entity) legalPersonNo: string; registerName: string; registerNameEN: string; registerDate: Date; authorizedCapital: string; } ) & { customerId: string; status?: Status; workplace: string; workplaceEN: string; address: string; addressEN: string; email: string; telephoneNo: string; employmentOffice: string; businessType: string; businessTypeEN: string; jobPosition: string; jobPositionEN: string; jobDescription: string; saleEmployee: string; payDate: Date; wageRate: number; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; }; export type CustomerBranchUpdate = ( | { // NOTE: About (Natural Person) citizenId: string; } | { // NOTE: About (Legal Entity) legalPersonNo: string; registerName?: string; registerNameEN?: string; registerDate?: Date; authorizedCapital?: string; } ) & { customerId?: string; status?: "ACTIVE" | "INACTIVE"; workplace: string; workplaceEN: string; address: string; addressEN: string; email?: string; telephoneNo?: string; employmentOffice?: string; bussinessType?: string; bussinessTypeEN?: string; jobPosition?: string; jobPositionEN?: string; jobDescription?: string; saleEmployee?: string; payDate?: Date; wageRate?: number; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; }; @Route("api/v1/customer-branch") @Tags("Customer Branch") export class CustomerBranchController extends Controller { @Get() @Security("keycloak") async list( @Query() zipCode?: string, @Query() customerId?: string, @Query() status?: Status, @Query() includeCustomer?: boolean, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { 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: [ { registerName: { contains: query } }, { registerNameEN: { contains: query } }, { email: { contains: query } }, { code: { contains: query } }, { address: { contains: query } }, { addressEN: { contains: query } }, { province: { name: { contains: query } } }, { province: { nameEN: { contains: query } } }, { district: { name: { contains: query } } }, { district: { nameEN: { contains: query } } }, { subDistrict: { name: { contains: query } } }, { subDistrict: { nameEN: { contains: query } } }, { customer: { OR: [ { firstName: { contains: query } }, { firstNameEN: { contains: query } }, { lastName: { contains: query } }, { lastNameEN: { contains: query } }, ], }, }, ], AND: { customerId, subDistrict: zipCode ? { zipCode } : undefined, ...filterStatus(status) }, } satisfies Prisma.CustomerBranchWhereInput; const [result, total] = await prisma.$transaction([ prisma.customerBranch.findMany({ orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], include: { customer: includeCustomer, province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, _count: true, }, where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.customerBranch.count({ where }), ]); return { result, page, pageSize, total }; } @Get("{branchId}") @Security("keycloak") async getById(@Path() branchId: string) { const record = await prisma.customerBranch.findFirst({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where: { id: branchId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } return record; } @Get("{branchId}/employee") @Security("keycloak") async listEmployee( @Path() branchId: string, @Query() zipCode?: string, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { const where = { AND: { customerBranchId: branchId }, OR: [ { firstName: { contains: query }, zipCode }, { firstNameEN: { contains: query }, zipCode }, { lastName: { contains: query }, zipCode }, { lastNameEN: { contains: query }, zipCode }, ], } satisfies Prisma.EmployeeWhereInput; const [result, total] = await prisma.$transaction([ prisma.employee.findMany({ orderBy: { createdAt: "asc" }, include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.employee.count({ where }), ]); return { result: await Promise.all( result.map(async (v) => ({ ...v, profileImageUrl: await minio.presignedGetObject( MINIO_BUCKET, imageLocation(v.id), 12 * 60 * 60, ), })), ), page, pageSize, total, }; } @Post() @Security("keycloak", MANAGE_ROLES) async create(@Request() req: RequestWithUser, @Body() body: CustomerBranchCreate) { const [province, district, subDistrict, customer] = await prisma.$transaction([ prisma.province.findFirst({ where: { id: body.provinceId || undefined } }), prisma.district.findFirst({ where: { id: body.districtId || undefined } }), prisma.subDistrict.findFirst({ where: { id: body.subDistrictId || undefined } }), prisma.customer.findFirst({ where: { id: body.customerId || undefined }, include: { branch: { take: 1, orderBy: { createdAt: "asc" }, }, }, }), ]); if (body.provinceId && !province) throw new HttpError( HttpStatus.BAD_REQUEST, "Province cannot be found.", "relationProvinceNotFound", ); if (body.districtId && !district) throw new HttpError( HttpStatus.BAD_REQUEST, "District cannot be found.", "relationDistrictNotFound", ); if (body.subDistrictId && !subDistrict) throw new HttpError( HttpStatus.BAD_REQUEST, "Sub-district cannot be found.", "relationSubDistrictNotFound", ); if (!customer) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer cannot be found.", "relationCustomerNotFound", ); const { provinceId, districtId, subDistrictId, customerId, ...rest } = body; const record = await prisma.$transaction( async (tx) => { const headofficeCode = customer.branch.at(0)?.code.slice(0, -3); let runningKey = ""; if (headofficeCode) { runningKey = `CUSTOMER_BRANCH_${headofficeCode}`; } else if ("citizenId" in body) { runningKey = `CUSTOMER_BRANCH_${body.citizenId}`; } else { runningKey = `CUSTOMER_BRANCH_${body.legalPersonNo}`; } const last = await tx.runningNo.upsert({ where: { key: runningKey }, create: { key: runningKey, value: 1, }, update: { value: { increment: 1 } }, }); return await tx.customerBranch.create({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, data: { ...rest, code: `${"citizenId" in body ? body.citizenId : body.legalPersonNo}-${last.value - 1}`, customer: { connect: { id: customerId } }, province: { connect: provinceId ? { id: provinceId } : undefined }, district: { connect: districtId ? { id: districtId } : undefined }, subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined }, createdBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } }, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); this.setStatus(HttpStatus.CREATED); return record; } @Put("{branchId}") @Security("keycloak", MANAGE_ROLES) async editById( @Request() req: RequestWithUser, @Body() body: CustomerBranchUpdate, @Path() branchId: string, ) { const branch = await prisma.customerBranch.findUnique({ where: { id: branchId } }); if (!branch) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } if (body.provinceId || body.districtId || body.subDistrictId || body.customerId) { const [province, district, subDistrict, customer] = await prisma.$transaction([ prisma.province.findFirst({ where: { id: body.provinceId || undefined } }), prisma.district.findFirst({ where: { id: body.districtId || undefined } }), prisma.subDistrict.findFirst({ where: { id: body.subDistrictId || undefined } }), prisma.customer.findFirst({ where: { id: body.customerId || undefined } }), ]); if (body.provinceId && !province) throw new HttpError( HttpStatus.BAD_REQUEST, "Province cannot be found.", "relationProvinceNotFound", ); if (body.districtId && !district) throw new HttpError( HttpStatus.BAD_REQUEST, "District cannot be found.", "relationDistrictNotFound", ); if (body.subDistrictId && !subDistrict) throw new HttpError( HttpStatus.BAD_REQUEST, "Sub-district cannot be found.", "relationSubDistrictNotFound", ); if (body.customerId && !customer) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer cannot be found.", "relationCustomerNotFound", ); } const { provinceId, districtId, subDistrictId, customerId, ...rest } = body; return await prisma.customerBranch.update({ where: { id: branchId }, include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), customer: { connect: customerId ? { id: customerId } : undefined }, province: { connect: provinceId ? { id: provinceId } : undefined, disconnect: provinceId === null || undefined, }, district: { connect: districtId ? { id: districtId } : undefined, disconnect: districtId === null || undefined, }, subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined, disconnect: subDistrictId === null || undefined, }, updatedBy: { connect: { id: req.user.sub } }, }, }); } @Delete("{branchId}") @Security("keycloak", MANAGE_ROLES) async delete(@Path() branchId: string) { const record = await prisma.customerBranch.findFirst({ where: { id: branchId }, }); if (!record) { throw new HttpError( HttpStatus.NOT_FOUND, "Customer branch cannot be found.", "customerBranchNotFound", ); } if (record.status !== Status.CREATED) { throw new HttpError( HttpStatus.FORBIDDEN, "Customer branch is in used.", "customerBranchInUsed", ); } return await prisma.customerBranch .delete({ include: { createdBy: true, updatedBy: true }, where: { id: branchId }, }) .then((v) => { new Promise((resolve, reject) => { const item: string[] = []; const stream = minio.listObjectsV2( MINIO_BUCKET, `${attachmentLocation(record.customerId, branchId)}/`, ); stream.on("data", (v) => v && v.name && item.push(v.name)); stream.on("end", () => resolve(item)); stream.on("error", () => reject(new Error("MinIO error."))); }).then((list) => { list.map(async (v) => { await minio.removeObject(MINIO_BUCKET, v, { forceDelete: true }); }); }); return v; }); } } @Route("api/v1/customer-branch/{branchId}/attachment") @Tags("Customer Branch") @Security("keycloak") export class CustomerAttachmentController extends Controller { @Get() async listAttachment(@Path() branchId: string) { const record = await prisma.customerBranch.findFirst({ where: { id: branchId }, }); if (!record) { throw new HttpError( HttpStatus.NOT_FOUND, "Customer branch cannot be found.", "customerBranchNotFound", ); } const list = await new Promise((resolve, reject) => { const item: string[] = []; const stream = minio.listObjectsV2( MINIO_BUCKET, `${attachmentLocation(record.customerId, branchId)}/`, ); stream.on("data", (v) => v && v.name && item.push(v.name)); stream.on("end", () => resolve(item)); stream.on("error", () => reject(new Error("MinIO error."))); }); return await Promise.all( list.map(async (v) => ({ name: v.split("/").at(-1) as string, url: await minio.presignedGetObject(MINIO_BUCKET, v, 12 * 60 * 60), })), ); } @Post() async addAttachment(@Path() branchId: string, @Body() payload: { file: string[] }) { const record = await prisma.customerBranch.findFirst({ where: { id: branchId }, }); if (!record) { throw new HttpError( HttpStatus.NOT_FOUND, "Customer branch cannot be found.", "customerBranchNotFound", ); } return await Promise.all( payload.file.map(async (v) => ({ name: v, url: await minio.presignedGetObject( MINIO_BUCKET, `${attachmentLocation(record.customerId, branchId)}/${v}`, ), uploadUrl: await minio.presignedPutObject( MINIO_BUCKET, `${attachmentLocation(record.customerId, branchId)}/${v}`, 12 * 60 * 60, ), })), ); } @Delete() async deleteAttachment(@Path() branchId: string, @Body() payload: { file: string[] }) { const record = await prisma.customerBranch.findFirst({ where: { id: branchId }, }); if (!record) { throw new HttpError( HttpStatus.NOT_FOUND, "Customer branch cannot be found.", "customerBranchNotFound", ); } await Promise.all( payload.file.map(async (v) => { await minio.removeObject( MINIO_BUCKET, `${attachmentLocation(record.customerId, branchId)}/${v}`, { forceDelete: true, }, ); }), ); } }