import { CustomerType, 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 minio, { presignedGetObjectIfExist } from "../services/minio"; import HttpStatus from "../interfaces/http-status"; import HttpError from "../interfaces/http-error"; 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", ]; export type CustomerCreate = { registeredBranchId?: string; code: string; status?: Status; personName: string; personNameEN?: string; customerType: CustomerType; customerName: string; customerNameEN: string; taxNo?: string | null; customerBranch?: { status?: Status; legalPersonNo: string; branchNo: number; taxNo: string | null; name: string; nameEN: string; addressEN: string; address: string; zipCode: string; email: string; telephoneNo: string; registerName: string; registerDate: Date; authorizedCapital: 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; }[]; }; export type CustomerUpdate = { registeredBranchId?: string; status?: "ACTIVE" | "INACTIVE"; personName?: string; personNameEN?: string; customerType?: CustomerType; customerName?: string; customerNameEN?: string; taxNo?: string | null; customerBranch?: { id?: string; status?: Status; legalPersonNo: string; branchNo: number; taxNo: string | null; name: string; nameEN: string; addressEN: string; address: string; zipCode: string; email: string; telephoneNo: string; registerName: string; registerDate: Date; authorizedCapital: 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; }[]; }; function imageLocation(id: string) { return `customer/${id}/profile-image`; } @Route("api/v1/customer") @Tags("Customer") export class CustomerController extends Controller { @Get("type-stats") @Security("keycloak") async stat() { const list = await prisma.customer.groupBy({ by: "customerType", _count: true, }); return list.reduce>( (a, c) => { a[c.customerType] = c._count; return a; }, { CORP: 0, PERS: 0, }, ); } @Get() @Security("keycloak") async list( @Query() customerType?: CustomerType, @Query() query: string = "", @Query() status?: Status, @Query() page: number = 1, @Query() pageSize: number = 30, @Query() includeBranch: boolean = false, ) { 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: [ { customerName: { contains: query }, customerType, ...filterStatus(status) }, { customerNameEN: { contains: query }, customerType, ...filterStatus(status) }, ], } satisfies Prisma.CustomerWhereInput; const [result, total] = await prisma.$transaction([ prisma.customer.findMany({ include: { _count: true, branch: includeBranch ? { include: { province: true, district: true, subDistrict: true, }, orderBy: { branchNo: "asc", }, } : undefined, createdBy: true, updatedBy: true, }, orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.customer.count({ where }), ]); return { result: await Promise.all( result.map(async (v) => ({ ...v, imageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(v.id), 12 * 60 * 60, ), })), ), page, pageSize, total, }; } @Get("{customerId}") @Security("keycloak") async getById(@Path() customerId: string) { const record = await prisma.customer.findFirst({ include: { branch: { include: { province: true, district: true, subDistrict: true, }, orderBy: { branchNo: "asc" }, }, createdBy: true, updatedBy: true, }, where: { id: customerId }, }); if (!record) throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "customerNotFound"); return Object.assign(record, { imageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Post() @Security("keycloak", MANAGE_ROLES) async create(@Request() req: RequestWithUser, @Body() body: CustomerCreate) { const { customerBranch, ...payload } = body; const provinceId = body.customerBranch?.reduce((acc, cur) => { if (cur.provinceId && !acc.includes(cur.provinceId)) return acc.concat(cur.provinceId); return acc; }, []); const districtId = body.customerBranch?.reduce((acc, cur) => { if (cur.districtId && !acc.includes(cur.districtId)) return acc.concat(cur.districtId); return acc; }, []); const subDistrictId = body.customerBranch?.reduce((acc, cur) => { if (cur.subDistrictId && !acc.includes(cur.subDistrictId)) return acc.concat(cur.subDistrictId); return acc; }, []); const [province, district, subDistrict, branch] = await prisma.$transaction([ prisma.province.findMany({ where: { id: { in: provinceId } } }), prisma.district.findMany({ where: { id: { in: districtId } } }), prisma.subDistrict.findMany({ where: { id: { in: subDistrictId } } }), prisma.branch.findFirst({ where: { id: body.registeredBranchId } }), ]); if (provinceId && province.length !== provinceId?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some province cannot be found.", "relationProvinceNotFound", ); } if (districtId && district.length !== districtId?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some district cannot be found.", "relationDistrictNotFound", ); } if (subDistrictId && subDistrict.length !== subDistrictId?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some sub district cannot be found.", "relationSubDistrictNotFound", ); } if (!!body.registeredBranchId && !branch) { throw new HttpError( HttpStatus.BAD_REQUEST, "Branch cannot be found.", "relationBranchNotFound", ); } if (!body.registeredBranchId) { body.registeredBranchId = undefined; } const record = await prisma.$transaction( async (tx) => { body.code = body.code.toLocaleUpperCase(); const exist = await tx.customer.findFirst({ where: { code: body.code }, }); if (exist) { throw new HttpError( HttpStatus.BAD_REQUEST, "Customer with same code already exists.", "sameCustomerCodeExists", ); } return await prisma.customer.create({ include: { branch: { include: { province: true, district: true, subDistrict: true, }, }, createdBy: true, updatedBy: true, }, data: { ...payload, statusOrder: +(payload.status === "INACTIVE"), code: `${body.code}000000`, branch: { createMany: { data: customerBranch?.map((v) => ({ ...v, code: body.code, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, })) || [], }, }, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); this.setStatus(HttpStatus.CREATED); return Object.assign(record, { imageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), imageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Put("{customerId}") @Security("keycloak", MANAGE_ROLES) async editById( @Path() customerId: string, @Request() req: RequestWithUser, @Body() body: CustomerUpdate, ) { const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) { throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "customerNotFound"); } const provinceId = body.customerBranch?.reduce((acc, cur) => { if (cur.provinceId && !acc.includes(cur.provinceId)) return acc.concat(cur.provinceId); return acc; }, []); const districtId = body.customerBranch?.reduce((acc, cur) => { if (cur.districtId && !acc.includes(cur.districtId)) return acc.concat(cur.districtId); return acc; }, []); const subDistrictId = body.customerBranch?.reduce((acc, cur) => { if (cur.subDistrictId && !acc.includes(cur.subDistrictId)) return acc.concat(cur.subDistrictId); return acc; }, []); const [province, district, subDistrict] = await prisma.$transaction([ prisma.province.findMany({ where: { id: { in: provinceId } } }), prisma.district.findMany({ where: { id: { in: districtId } } }), prisma.subDistrict.findMany({ where: { id: { in: subDistrictId } } }), ]); if (provinceId && province.length !== provinceId?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some province cannot be found.", "relationProvinceNotFound", ); } if (districtId && district.length !== districtId?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some district cannot be found.", "relationDistrictNotFound", ); } if (subDistrictId && subDistrict.length !== subDistrictId?.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some sub district cannot be found.", "relationSubDistrictNotFound", ); } const { customerBranch, ...payload } = body; const relation = await prisma.customerBranch.findMany({ where: { customerId, }, }); if ( customerBranch && relation.find((a) => !customerBranch.find((b) => a.id === b.id) && a.status !== "CREATED") ) { throw new HttpError( HttpStatus.BAD_REQUEST, "One or more branch cannot be delete and is missing.", "oneOrMoreBranchMissing", ); } if ( customerBranch && relation.find((a) => customerBranch.find((b) => a.id !== b.id && a.branchNo === b.branchNo)) ) { throw new HttpError( HttpStatus.BAD_REQUEST, "Branch cannot have same number.", "oneOrMoreBranchNoExist", ); } const record = await prisma.customer .update({ include: { branch: { include: { province: true, district: true, subDistrict: true, }, }, createdBy: true, updatedBy: true, }, where: { id: customerId }, data: { ...payload, statusOrder: +(payload.status === "INACTIVE"), branch: (customerBranch && { deleteMany: { id: { notIn: customerBranch.map((v) => v.id).filter((v): v is string => !!v) || [], }, status: Status.CREATED, }, upsert: customerBranch.map((v) => ({ where: { id: v.id || "" }, create: { ...v, code: `${customer.code}-${v.branchNo.toString().padStart(2, "0")}`, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, id: undefined, }, update: { ...v, code: undefined, branchNo: undefined, updatedByUserId: req.user.sub, }, })), }) || undefined, updatedByUserId: req.user.sub, }, }) .then((v) => { if (customerBranch) { relation .filter((a) => !customerBranch.find((b) => b.id === a.id)) .forEach((deleted) => { new Promise((resolve, reject) => { const item: string[] = []; const stream = minio.listObjectsV2(MINIO_BUCKET, `customer/${deleted.id}`); 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; }); return Object.assign(record, { imageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), imageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Delete("{customerId}") @Security("keycloak", MANAGE_ROLES) async deleteById(@Path() customerId: string) { const record = await prisma.customer.findFirst({ where: { id: customerId } }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "customerNotFound"); } if (record.status !== Status.CREATED) { throw new HttpError(HttpStatus.FORBIDDEN, "Customer is in used.", "customerInUsed"); } return await prisma.customer.delete({ where: { id: customerId } }).then((v) => { new Promise((resolve, reject) => { const item: string[] = []; const stream = minio.listObjectsV2(MINIO_BUCKET, `customer/${customerId}`); 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; }); } @Get("{customerId}/image") async getCustomerImageById(@Request() req: RequestWithUser, @Path() customerId: string) { const url = await presignedGetObjectIfExist(MINIO_BUCKET, imageLocation(customerId), 60 * 60); if (!url) { throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound"); } return req.res?.redirect(url); } @Put("{customerId}/image") @Security("keycloak", MANAGE_ROLES) async setCustomerImageById(@Request() req: RequestWithUser, @Path() customerId: string) { const record = await prisma.customer.findFirst({ where: { id: customerId, }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "customerNotFound"); } return req.res?.redirect( await minio.presignedPutObject(MINIO_BUCKET, imageLocation(customerId), 12 * 60 * 60), ); } }