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, { presignedGetObjectIfExist } from "../services/minio"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MINIO_BUCKET = process.env.MINIO_BUCKET; type BranchCreate = { status?: Status; code: string; 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; bank?: { bankName: string; bankBranch: string; accountName: string; accountNumber: string; accountType: string; currentlyUse: boolean; }[]; 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; bank?: { bankName: string; bankBranch: string; accountName: string; accountNumber: string; accountType: string; currentlyUse: boolean; }[]; }; function lineImageLoc(id: string) { return `branch/line-qr-${id}`; } function branchImageLoc(id: string) { return `branch/branch-img-${id}`; } @Route("api/v1/branch") @Tags("Branch") export class BranchController extends Controller { @Get("stats") @Security("keycloak") 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") @Security("keycloak") async getUserStat(@Request() req: RequestWithUser, @Query() userType?: UserType) { const list = await prisma.branchUser.groupBy({ _count: true, where: { userId: !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) ? req.user.sub : undefined, user: { userType, }, }, by: "branchId", }); const record = await prisma.branch.findMany({ where: { user: !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) ? { some: { userId: req.user.sub } } : undefined, }, 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() @Security("keycloak") 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, }, }, bank: true, _count: { select: { branch: true }, }, createdBy: true, updatedBy: true, }, where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.branch.count({ where }), ]); return { result, page, pageSize, total }; } @Get("{branchId}") @Security("keycloak") async getBranchById( @Path() branchId: string, @Query() includeSubBranch?: boolean, @Query() includeContact?: boolean, ) { const record = await prisma.branch.findFirst({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, branch: includeSubBranch && { include: { province: true, district: true, subDistrict: true, }, }, bank: 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() @Security("keycloak", ["system", "head_of_admin", "admin"]) 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, bank, contact, code, ...rest } = body; if (headOfficeId && head && head.code.slice(0, -6) !== code) { throw new HttpError( HttpStatus.BAD_REQUEST, "Headoffice code not match with branch code", "codeMismatch", ); } const record = await prisma.$transaction( async (tx) => { const last = await tx.runningNo.upsert({ where: { key: `MAIN_BRANCH_${code}`, }, create: { key: `MAIN_BRANCH_${code}`, value: 1, }, update: { value: { increment: 1 } }, }); if (last.value === 1) { const exist = await tx.branch.findFirst({ where: { code: `${code?.toLocaleUpperCase()}${`${last.value - 1}`.padStart(6, "0")}` }, }); if (exist) throw new HttpError( HttpStatus.BAD_REQUEST, "Branch with same code already exists.", "sameBranchCodeExists", ); } if (last.value !== 1 && !headOfficeId) { throw new HttpError( HttpStatus.BAD_REQUEST, "Branch with same code already exists.", "sameBranchCodeExists", ); } return await tx.branch.create({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), code: `${code?.toLocaleUpperCase()}${`${last.value - 1}`.padStart(6, "0")}`, bank: bank ? { createMany: { data: bank } } : undefined, 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), 12 * 60 * 60, ), qrCodeImageUrl: await minio.presignedGetObject(MINIO_BUCKET, lineImageLoc(record.id)), qrCodeImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, lineImageLoc(record.id), 12 * 60 * 60, ), }); } @Put("{branchId}") @Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"]) 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, bank, contact, ...rest } = body; const branch = await prisma.branch.findUnique({ include: { user: { where: { userId: req.user.sub } }, }, where: { id: branchId }, }); if (!branch) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } if ( !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) && !branch?.user.find((v) => v.userId === req.user.sub) ) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } 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, bank: bank ? { deleteMany: {}, createMany: { data: bank } } : 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), 12 * 60 * 60, ), qrCodeImageUrl: await minio.presignedGetObject(MINIO_BUCKET, lineImageLoc(record.id)), qrCodeImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, lineImageLoc(record.id), 12 * 60 * 60, ), }); } @Delete("{branchId}") @Security("keycloak", ["system", "head_of_admin", "admin", "branch_manager"]) async deleteBranch(@Request() req: RequestWithUser, @Path() branchId: string) { const record = await prisma.branch.findFirst({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, user: { where: { userId: req.user.sub } }, }, where: { id: branchId }, }); if (!["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v))) { if ( record?.createdByUserId !== req.user.sub && !record?.user.find((v) => v.userId === req.user.sub) ) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } } 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"); } return await prisma.$transaction(async (tx) => { const data = await tx.branch.delete({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where: { id: branchId }, }); if (record.isHeadOffice) { await tx.runningNo.delete({ where: { key: `MAIN_BRANCH_${record.code.slice(0, -6)}`, }, }); } await minio.removeObject(MINIO_BUCKET, lineImageLoc(branchId), { forceDelete: true, }); await minio.removeObject(MINIO_BUCKET, branchImageLoc(branchId), { forceDelete: true, }); return data; }); } @Get("{branchId}/line-image") async getLineImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) { const url = await presignedGetObjectIfExist(MINIO_BUCKET, lineImageLoc(branchId), 60 * 60); if (!url) { throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound"); } return req.res?.redirect(url); } @Put("{branchId}/line-image") @Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"]) async setLineImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) { const record = await prisma.branch.findUnique({ include: { user: { where: { userId: req.user.sub } }, }, where: { id: branchId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } if ( !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) && !record?.user.find((v) => v.userId === req.user.sub) ) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } return req.res?.redirect( await minio.presignedPutObject(MINIO_BUCKET, lineImageLoc(record.id), 12 * 60 * 60), ); } @Get("{branchId}/branch-image") async getBranchImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) { const url = await presignedGetObjectIfExist(MINIO_BUCKET, branchImageLoc(branchId), 60 * 60); if (!url) { throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound"); } return req.res?.redirect(url); } @Put("{branchId}/branch-image") @Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"]) async setBranchImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) { const record = await prisma.branch.findUnique({ include: { user: { where: { userId: req.user.sub } }, }, where: { id: branchId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound"); } if ( !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) && !record?.user.find((v) => v.userId === req.user.sub) ) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } return req.res?.redirect( await minio.presignedPutObject(MINIO_BUCKET, branchImageLoc(record.id), 12 * 60 * 60), ); } }