import { Prisma, Status, UserType } from "@prisma/client"; import { Body, Controller, Delete, Get, Put, Path, Post, Query, Request, Route, Security, Tags, } from "tsoa"; import prisma from "../db"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { RequestWithUser } from "../interfaces/user"; import minio from "../services/minio"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MINIO_BUCKET = process.env.MINIO_BUCKET; type BranchCreate = { status?: Status; taxNo: string; nameEN: string; name: string; addressEN: string; address: string; zipCode: string; email: string; contactName?: string | null; contact?: string | string[] | null; telephoneNo: string; lineId?: string | null; longitude: string; latitude: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; headOfficeId?: string | null; }; type BranchUpdate = { status?: "ACTIVE" | "INACTIVE"; taxNo?: string; nameEN?: string; name?: string; addressEN?: string; address?: string; zipCode?: string; email?: string; telephoneNo?: string; contactName?: string; contact?: string | string[] | null; lineId?: string; longitude?: string; latitude?: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; headOfficeId?: string | null; }; function lineImageLoc(id: string) { return `branch/line-qr-${id}`; } function branchImageLoc(id: string) { return `branch/branch-img-${id}`; } @Route("api/v1/branch") @Tags("Branch") @Security("keycloak") export class BranchController extends Controller { @Get("stats") async getStats() { const list = await prisma.branch.groupBy({ _count: true, by: "isHeadOffice", }); return list.reduce>( (a, c) => { a[c.isHeadOffice ? "hq" : "br"] = c._count; return a; }, { hq: 0, br: 0 }, ); } @Get("user-stats") async getUserStat(@Query() userType?: UserType) { const list = await prisma.branchUser.groupBy({ _count: true, where: { user: { userType } }, by: "branchId", }); const record = await prisma.branch.findMany({ select: { id: true, headOfficeId: true, isHeadOffice: true, nameEN: true, name: true, }, orderBy: [{ isHeadOffice: "desc" }, { createdAt: "asc" }], }); const sort = record.reduce<(typeof record)[]>((acc, curr) => { for (const i of acc) { if (i[0].id === curr.headOfficeId) { i.push(curr); return acc; } } acc.push([curr]); return acc; }, []); return sort.flat().map((a) => Object.assign(a, { count: list.find((b) => b.branchId === a.id)?._count ?? 0, }), ); } @Get() async getBranch( @Query() zipCode?: string, @Query() filter?: "head" | "sub", @Query() headOfficeId?: string, @Query() tree?: boolean, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { const where = { AND: { headOfficeId: headOfficeId ?? (filter === "head" || tree ? null : undefined), NOT: { headOfficeId: filter === "sub" && !headOfficeId ? null : undefined }, }, OR: [ { nameEN: { contains: query }, zipCode }, { name: { contains: query }, zipCode }, { email: { contains: query }, zipCode }, { telephoneNo: { contains: query }, zipCode }, ], } satisfies Prisma.BranchWhereInput; const [result, total] = await prisma.$transaction([ prisma.branch.findMany({ orderBy: { createdAt: "asc" }, include: { province: true, district: true, subDistrict: true, contact: true, branch: tree && { include: { province: true, district: true, subDistrict: true, }, }, }, where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.branch.count({ where }), ]); return { result, page, pageSize, total }; } @Get("{branchId}") async getBranchById( @Path() branchId: string, @Query() includeSubBranch?: boolean, @Query() includeContact?: boolean, ) { const record = await prisma.branch.findFirst({ include: { province: true, district: true, subDistrict: true, branch: includeSubBranch && { include: { province: true, district: true, subDistrict: true, }, }, contact: includeContact, }, where: { id: branchId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } return Object.assign(record, { imageUrl: await minio.presignedGetObject(MINIO_BUCKET, branchImageLoc(record.id)), qrCodeImageUrl: await minio.presignedGetObject(MINIO_BUCKET, lineImageLoc(record.id)), }); } @Post() async createBranch(@Request() req: RequestWithUser, @Body() body: BranchCreate) { const [province, district, subDistrict, head] = 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.branch.findFirst({ where: { id: body.headOfficeId || 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.headOfficeId && !head) throw new HttpError( HttpStatus.BAD_REQUEST, "Headquaters cannot be found.", "relationHQNotFound", ); const { provinceId, districtId, subDistrictId, headOfficeId, contact, ...rest } = body; const year = new Date().getFullYear(); const record = await prisma.$transaction( async (tx) => { const last = await tx.runningNo.upsert({ where: { key: !headOfficeId ? `HQ${year.toString().slice(2)}` : `BR${head?.code.slice(2, 5)}`, }, create: { key: !headOfficeId ? `HQ${year.toString().slice(2)}` : `BR${head?.code.slice(2, 5)}`, value: 1, }, update: { value: { increment: 1 } }, }); const code = !headOfficeId ? `HQ${year.toString().slice(2)}${last.value}` : `BR${head?.code.slice(2, 5)}${last.value.toString().padStart(2, "0")}`; return await tx.branch.create({ include: { province: true, district: true, subDistrict: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), code, isHeadOffice: !headOfficeId, province: { connect: provinceId ? { id: provinceId } : undefined }, district: { connect: districtId ? { id: districtId } : undefined }, subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined }, headOffice: { connect: headOfficeId ? { id: headOfficeId } : undefined }, createdBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } }, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); if (headOfficeId) { await prisma.branch.updateMany({ where: { id: headOfficeId, status: Status.CREATED }, data: { status: Status.ACTIVE }, }); } this.setStatus(HttpStatus.CREATED); if (record && contact) { await prisma.branchContact.createMany({ data: typeof contact === "string" ? [{ telephoneNo: contact, branchId: record.id }] : contact.map((v) => ({ telephoneNo: v, branchId: record.id })), }); } return Object.assign(record, { contact: await prisma.branchContact.findMany({ where: { branchId: record.id } }), imageUrl: await minio.presignedGetObject(MINIO_BUCKET, branchImageLoc(record.id)), imageUploadUrl: await minio.presignedPutObject(MINIO_BUCKET, branchImageLoc(record.id)), qrCodeImageUrl: await minio.presignedGetObject( MINIO_BUCKET, lineImageLoc(record.id), 12 * 60 * 60, ), qrCodeImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, lineImageLoc(record.id), 12 * 60 * 60, ), }); } @Put("{branchId}") async editBranch( @Request() req: RequestWithUser, @Body() body: BranchUpdate, @Path() branchId: string, ) { if (body.headOfficeId === branchId) throw new HttpError( HttpStatus.BAD_REQUEST, "Cannot make this as headquaters and branch at the same time.", "cantMakeHQAndBranchSameTime", ); if (body.subDistrictId || body.districtId || body.provinceId || body.headOfficeId) { const [province, district, subDistrict, branch] = 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.branch.findFirst({ where: { id: body.headOfficeId || 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.headOfficeId && !branch) throw new HttpError( HttpStatus.BAD_REQUEST, "Headquaters cannot be found.", "relationHQNotFound", ); } const { provinceId, districtId, subDistrictId, headOfficeId, contact, ...rest } = body; if (!(await prisma.branch.findUnique({ where: { id: branchId } }))) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } const record = await prisma.branch.update({ include: { province: true, district: true, subDistrict: true }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), isHeadOffice: headOfficeId !== undefined ? headOfficeId === null : 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, }, headOffice: { connect: headOfficeId ? { id: headOfficeId } : undefined, disconnect: headOfficeId === null || undefined, }, updatedBy: { connect: { id: req.user.sub } }, }, where: { id: branchId }, }); if (record && contact !== undefined) { await prisma.branchContact.deleteMany({ where: { branchId } }); contact && (await prisma.branchContact.createMany({ data: typeof contact === "string" ? [{ telephoneNo: contact, branchId }] : contact.map((v) => ({ telephoneNo: v, branchId })), })); } return Object.assign(record, { imageUrl: await minio.presignedGetObject(MINIO_BUCKET, branchImageLoc(record.id)), imageUploadUrl: await minio.presignedPutObject(MINIO_BUCKET, branchImageLoc(record.id)), qrCodeImageUrl: await minio.presignedGetObject( MINIO_BUCKET, lineImageLoc(record.id), 12 * 60 * 60, ), qrCodeImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, lineImageLoc(record.id), 12 * 60 * 60, ), }); } @Delete("{branchId}") async deleteBranch(@Path() branchId: string) { const record = await prisma.branch.findFirst({ include: { province: true, district: true, subDistrict: true, }, where: { id: branchId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } if (record.status !== Status.CREATED) { throw new HttpError(HttpStatus.FORBIDDEN, "Branch is in used.", "branchInUsed"); } await minio.removeObject(MINIO_BUCKET, lineImageLoc(branchId), { forceDelete: true, }); await minio.removeObject(MINIO_BUCKET, branchImageLoc(branchId), { forceDelete: true, }); return await prisma.branch.delete({ include: { province: true, district: true, subDistrict: true, }, where: { id: branchId }, }); } }