jws-backend/src/controllers/01-branch-controller.ts
Methapon Metanipat d5ab07bacb refactor: upload now return presigned url instead of redirect
This will prevent from payload upload to API Server first and then
upload to MinIO which cause upload to be happens twice.
2024-10-24 09:56:37 +07:00

754 lines
22 KiB
TypeScript

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, 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_account", "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;
permitExpireDate: Date;
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;
permitExpireDate?: Date;
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, 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() query: string = "",
@Query() page: number = 1,
@Query() pageSize: number = 30,
) {
const where = {
AND: {
...filterStatus(status),
headOfficeId: headOfficeId ?? (filter === "head" || tree ? null : undefined),
NOT: { headOfficeId: filter === "sub" && !headOfficeId ? null : undefined },
OR: permissionCond(req.user, withHead),
},
OR: [
{ 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,
},
}
: 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,
},
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 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 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));
}
}