import { Prisma, Status, UserType } from "@prisma/client"; import { Body, Controller, Delete, Get, Put, Path, Post, Query, Request, Route, Security, Tags, Head, } from "tsoa"; import prisma from "../db"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { RequestWithUser } from "../interfaces/user"; import { deleteFile, deleteFolder, fileLocation, getFile, getPresigned, listFile, setFile, } from "../utils/minio"; import { branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; import { filterStatus } from "../services/prisma"; import { connectOrDisconnect, connectOrNot, queryOrNot, whereAddressQuery, } from "../utils/relation"; import { isUsedError, notFoundError, relationError } from "../utils/error"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MANAGE_ROLES = ["system", "head_of_admin"]; function globalAllow(user: RequestWithUser["user"]) { return MANAGE_ROLES.some((v) => user.roles?.includes(v)); } function globalAllowView(user: RequestWithUser["user"]) { return MANAGE_ROLES.concat("head_of_accountant", "head_of_sale").some((v) => user.roles?.includes(v), ); } type BranchCreate = { status?: Status; code: string; taxNo: string; nameEN: string; name: string; permitNo: string; permitIssueDate?: Date | null; permitExpireDate?: Date | null; addressEN: string; address: string; soi?: string | null; soiEN?: string | null; moo?: string | null; mooEN?: string | null; street?: string | null; streetEN?: string | null; email: string; contactName?: string | null; webUrl?: string | null; contact?: string | string[] | null; telephoneNo: string; lineId?: string | null; longitude: string; latitude: string; virtual?: boolean; selectedImage?: string; remark?: 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; permitNo?: string; permitIssueDate?: Date | null; permitExpireDate?: Date | null; addressEN?: string; address?: string; soi?: string | null; soiEN?: string | null; moo?: string | null; mooEN?: string | null; street?: string | null; streetEN?: string | null; email?: string; telephoneNo?: string; contactName?: string; webUrl?: string | null; contact?: string | string[] | null; lineId?: string; longitude?: string; latitude?: string; virtual?: boolean; selectedImage?: string; remark?: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; headOfficeId?: string | null; bank?: { id?: string; bankName: string; bankBranch: string; accountName: string; accountNumber: string; accountType: string; currentlyUse: boolean; }[]; }; const permissionCond = createPermCondition(globalAllowView); const permissionCheck = createPermCheck(globalAllow); @Route("api/v1/branch") @Tags("Branch") export class BranchController extends Controller { @Get("stats") @Security("keycloak") async getStats(@Request() req: RequestWithUser, @Query() headOfficeId?: string) { const where = { AND: { OR: permissionCond(req.user, { alwaysIncludeHead: true }), }, }; const [hq, br, virtual] = await prisma.$transaction([ prisma.branch.count({ where: { id: headOfficeId ? headOfficeId : undefined, headOfficeId: null, ...where, }, }), prisma.branch.count({ where: { headOfficeId: headOfficeId ? headOfficeId : { not: null }, virtual: false, ...where, }, }), prisma.branch.count({ where: { headOfficeId: headOfficeId ? headOfficeId : { not: null }, virtual: true, ...where, }, }), ]); return { hq, br, virtual }; } @Get("user-stats") @Security("keycloak") async getUserStat(@Request() req: RequestWithUser, @Query() userType?: UserType) { const list = await prisma.branchUser.groupBy({ _count: true, where: { userId: !MANAGE_ROLES.some((v) => req.user.roles?.includes(v)) ? req.user.sub : undefined, user: { userType, }, }, by: "branchId", }); const record = await prisma.branch.findMany({ where: { user: !MANAGE_ROLES.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( @Request() req: RequestWithUser, @Query() filter?: "head" | "sub", @Query() headOfficeId?: string, @Query() includeHead?: boolean, // Include relation @Query() withHead?: boolean, // List cover head @Query() tree?: boolean, @Query() status?: Status, @Query() activeOnly?: boolean, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { const where = { AND: { ...filterStatus(activeOnly ? Status.ACTIVE : status), AND: activeOnly ? { OR: [{ headOffice: { status: { not: Status.INACTIVE } } }, { headOffice: null }], } : undefined, headOfficeId: headOfficeId ?? (filter === "head" || tree ? null : undefined), NOT: { headOfficeId: filter === "sub" && !headOfficeId ? null : undefined }, OR: permissionCond(req.user, { alwaysIncludeHead: withHead, activeOnly }), }, OR: queryOrNot(query, [ { code: { contains: query, mode: "insensitive" } }, { nameEN: { contains: query } }, { name: { contains: query } }, { email: { contains: query } }, { telephoneNo: { contains: query } }, ...whereAddressQuery(query), { branch: { some: { OR: [ { code: { contains: query, mode: "insensitive" } }, { nameEN: { contains: query } }, { name: { contains: query } }, { email: { contains: query } }, { telephoneNo: { contains: query } }, ...whereAddressQuery(query), ], }, }, }, ]), } satisfies Prisma.BranchWhereInput; const [result, total] = await prisma.$transaction([ prisma.branch.findMany({ orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], include: { province: true, district: true, subDistrict: true, contact: true, headOffice: includeHead ? { include: { province: true, district: true, subDistrict: true, }, } : false, branch: tree ? { where: { AND: { OR: permissionCond(req.user) }, OR: [ { nameEN: { contains: query } }, { name: { contains: query } }, { email: { contains: query } }, { telephoneNo: { contains: query } }, ...whereAddressQuery(query), ], }, include: { province: true, district: true, subDistrict: true, }, orderBy: { code: "asc" }, } : false, 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( @Request() req: RequestWithUser, @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 && { where: { AND: { OR: permissionCond(req.user) } }, include: { province: true, district: true, subDistrict: true, bank: true, contact: includeContact, }, orderBy: { code: "asc" }, }, bank: true, contact: includeContact, }, where: { id: branchId }, }); if (!record) throw notFoundError("Branch"); return record; } @Post() @Security("keycloak", MANAGE_ROLES) 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 relationError("Province"); if (body.districtId && !district) throw relationError("District"); if (body.subDistrictId && !subDistrict) throw relationError("SubDistrict"); if (body.headOfficeId && !head) throw relationError("HQ"); const { provinceId, districtId, subDistrictId, headOfficeId, bank, contact, code, ...rest } = body; if (headOfficeId && head && head.code.slice(0, -5) !== 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.toLocaleUpperCase()}`, }, create: { key: `MAIN_BRANCH_${code.toLocaleUpperCase()}`, value: 1, }, update: { value: { increment: 1 } }, }); const errorBranchExists = new HttpError( HttpStatus.BAD_REQUEST, "Branch with same code already exists.", "sameBranchCodeExists", ); if (last.value === 1) { const exist = await tx.branch.findFirst({ where: { code: `${code?.toLocaleUpperCase()}${`${last.value - 1}`.padStart(5, "0")}` }, }); if (exist) throw errorBranchExists; } if (last.value !== 1 && !headOfficeId) throw errorBranchExists; return await tx.branch.create({ include: { province: true, district: true, subDistrict: true, contact: true, bank: true, createdBy: true, updatedBy: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), code: `${code?.toLocaleUpperCase()}${`${last.value - 1}`.padStart(5, "0")}`, bank: bank ? { createMany: { data: bank } } : undefined, isHeadOffice: !headOfficeId, contact: { create: (typeof contact === "string" ? [contact] : contact)?.map((v) => ({ telephoneNo: v, })), }, province: connectOrNot(provinceId), district: connectOrNot(districtId), subDistrict: connectOrNot(subDistrictId), headOffice: connectOrNot(headOfficeId), 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); return record; } @Put("{branchId}") @Security("keycloak", MANAGE_ROLES.concat("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 headquarters 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 relationError("Province"); if (body.districtId && !district) throw relationError("District"); if (body.subDistrictId && !subDistrict) throw relationError("SubDistrict"); if (body.headOfficeId && !branch) throw relationError("HQ"); } const { provinceId, districtId, subDistrictId, headOfficeId, bank, contact, ...rest } = body; await permissionCheck(req.user, branchId); return await prisma.$transaction(async (tx) => { const listDeleted = bank ? await tx.branchBank.findMany({ where: { id: { not: { in: bank.flatMap((v) => (!!v.id ? v.id : [])) } }, branchId }, }) : []; await Promise.all( listDeleted.map((v) => deleteFile(fileLocation.branch.bank(v.branchId, v.id))), ); return await prisma.branch.update({ include: { province: true, district: true, subDistrict: true, contact: true, bank: true, createdBy: true, updatedBy: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), isHeadOffice: headOfficeId !== undefined ? headOfficeId === null : undefined, bank: bank ? { deleteMany: listDeleted.length > 0 ? { id: { in: listDeleted.map((v) => v.id) } } : undefined, upsert: bank.map((v) => ({ where: { id: v.id || "" }, create: { ...v, id: undefined }, update: v, })), } : undefined, province: connectOrDisconnect(provinceId), district: connectOrDisconnect(districtId), subDistrict: connectOrDisconnect(subDistrictId), headOffice: connectOrDisconnect(headOfficeId), contact: contact ? { deleteMany: {}, create: (typeof contact === "string" ? [contact] : contact)?.map((v) => ({ telephoneNo: v, })), } : undefined, updatedBy: { connect: { id: req.user.sub } }, }, where: { id: branchId }, }); }); } @Delete("{branchId}") @Security("keycloak", MANAGE_ROLES) async deleteBranch(@Request() req: RequestWithUser, @Path() branchId: string) { const record = await prisma.branch.findUnique({ include: branchRelationPermInclude(req.user), where: { id: branchId }, }); if (!record) throw notFoundError("Branch"); await permissionCheck(req.user, record); if (record.status !== Status.CREATED) throw isUsedError("Branch"); return await prisma.$transaction(async (tx) => { const data = await tx.branch.delete({ include: { province: true, district: true, subDistrict: true, contact: true, bank: true, createdBy: true, updatedBy: true, }, where: { id: branchId }, }); if (record.isHeadOffice) { await tx.runningNo.delete({ where: { key: `MAIN_BRANCH_${record.code.slice(0, -5)}`, }, }); } await Promise.all([ deleteFolder(fileLocation.branch.img(branchId)), deleteFile(fileLocation.branch.line(branchId)), ...data.bank.map((v) => deleteFile(fileLocation.branch.bank(branchId, v.id))), ]); return data; }); } } @Route("api/v1/branch/{branchId}") @Tags("Branch") export class BranchFileController extends Controller { private async checkPermission(user: RequestWithUser["user"], id: string) { const data = await prisma.branch.findUnique({ include: branchRelationPermInclude(user), where: { id }, }); if (!data) throw notFoundError("Branch"); await permissionCheck(user, data); } @Get("image") @Security("keycloak") async listImage(@Request() req: RequestWithUser, @Path() branchId: string) { await this.checkPermission(req.user, branchId); return await listFile(fileLocation.branch.img(branchId)); } @Get("image/{name}") async getImage(@Request() req: RequestWithUser, @Path() branchId: string, @Path() name: string) { return req.res?.redirect(await getFile(fileLocation.branch.img(branchId, name))); } @Head("image/{name}") async headImage(@Request() req: RequestWithUser, @Path() branchId: string, @Path() name: string) { return req.res?.redirect(await getPresigned("head", fileLocation.branch.img(branchId, name))); } @Put("image/{name}") @Security("keycloak") async putImage(@Request() req: RequestWithUser, @Path() branchId: string, @Path() name: string) { if (!req.headers["content-type"]?.startsWith("image/")) { throw new HttpError(HttpStatus.BAD_REQUEST, "Not a valid image.", "notValidImage"); } await this.checkPermission(req.user, branchId); return req.res?.redirect(await setFile(fileLocation.branch.img(branchId, name))); } @Delete("image/{name}") @Security("keycloak") async delImage(@Request() req: RequestWithUser, @Path() branchId: string, @Path() name: string) { await this.checkPermission(req.user, branchId); return await deleteFile(fileLocation.branch.img(branchId, name)); } @Get("bank-qr/{bankId}") async getBankImage( @Request() req: RequestWithUser, @Path() branchId: string, @Path() bankId: string, ) { return req.res?.redirect(await getFile(fileLocation.branch.bank(branchId, bankId))); } @Head("bank-qr/{bankId}") async headBankImage( @Request() req: RequestWithUser, @Path() branchId: string, @Path() bankId: string, ) { return req.res?.redirect( await getPresigned("head", fileLocation.branch.bank(branchId, bankId)), ); } @Put("bank-qr/{bankId}") @Security("keycloak") async putBankImage( @Request() req: RequestWithUser, @Path() branchId: string, @Path() bankId: string, ) { if (!req.headers["content-type"]?.startsWith("image/")) { throw new HttpError(HttpStatus.BAD_REQUEST, "Not a valid image.", "notValidImage"); } await this.checkPermission(req.user, branchId); return req.res?.redirect(await setFile(fileLocation.branch.bank(branchId, bankId))); } @Delete("bank-qr/{bankId}") @Security("keycloak") async delBankImage( @Request() req: RequestWithUser, @Path() branchId: string, @Path() bankId: string, ) { await this.checkPermission(req.user, branchId); return await deleteFile(fileLocation.branch.bank(branchId, bankId)); } @Get("line-image") async getLineImage(@Request() req: RequestWithUser, @Path() branchId: string) { return req.res?.redirect(await getFile(fileLocation.branch.line(branchId))); } @Head("line-image") async headLineImage(@Request() req: RequestWithUser, @Path() branchId: string) { return req.res?.redirect(await getPresigned("head", fileLocation.branch.line(branchId))); } @Put("line-image") @Security("keycloak") async putLineImage(@Request() req: RequestWithUser, @Path() branchId: string) { if (!req.headers["content-type"]?.startsWith("image/")) { throw new HttpError(HttpStatus.BAD_REQUEST, "Not a valid image.", "notValidImage"); } await this.checkPermission(req.user, branchId); return req.res?.redirect(await setFile(fileLocation.branch.line(branchId))); } @Delete("line-image") @Security("keycloak") async delLineImage(@Request() req: RequestWithUser, @Path() branchId: string) { await this.checkPermission(req.user, branchId); return await deleteFile(fileLocation.branch.line(branchId)); } @Get("attachment") @Security("keycloak") async listAttachment(@Request() req: RequestWithUser, @Path() branchId: string) { await this.checkPermission(req.user, branchId); return await listFile(fileLocation.branch.attachment(branchId)); } @Get("attachment/{name}") @Security("keycloak") async getAttachment(@Path() branchId: string, @Path() name: string) { return await getFile(fileLocation.branch.attachment(branchId, name)); } @Head("attachment/{name}") @Security("keycloak") async headAttachment(@Path() branchId: string, @Path() name: string) { return await getPresigned("head", fileLocation.branch.attachment(branchId, name)); } @Put("attachment/{name}") @Security("keycloak") async putAttachment( @Request() req: RequestWithUser, @Path() branchId: string, @Path() name: string, ) { await this.checkPermission(req.user, branchId); return await setFile(fileLocation.branch.attachment(branchId, name)); } @Delete("attachment/{name}") @Security("keycloak") async delAttachment( @Request() req: RequestWithUser, @Path() branchId: string, @Path() name: string, ) { await this.checkPermission(req.user, branchId); return await deleteFile(fileLocation.branch.attachment(branchId, name)); } }