import { Body, Controller, Delete, Get, Put, Path, Post, Query, Request, Route, Security, Tags, } from "tsoa"; import { Branch, Prisma, Status, User, UserType } from "@prisma/client"; import prisma from "../db"; import { RequestWithUser } from "../interfaces/user"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { addUserRoles, createUser, deleteUser, editUser, listRole, getUserRoles, removeUserRoles, } from "../services/keycloak"; import { isSystem } from "../utils/keycloak"; import { deleteFile, deleteFolder, fileLocation, getFile, listFile, setFile } from "../utils/minio"; import { filterStatus } from "../services/prisma"; import { branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; 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", "admin", "branch_manager"]; function globalAllow(user: RequestWithUser["user"]) { const listAllowed = ["system", "head_of_admin"]; return user.roles?.some((v) => listAllowed.includes(v)) || false; } type UserCreate = { status?: Status; userType: UserType; userRole: string; username: string; citizenId: string; citizenIssue: Date; citizenExpire?: Date | null; namePrefix?: string | null; firstName: string; firstNameEN: string; middleName?: string | null; middleNameEN?: string | null; lastName: string; lastNameEN: string; gender: string; checkpoint?: string | null; checkpointEN?: string | null; registrationNo?: string | null; startDate?: Date | null; retireDate?: Date | null; discountCondition?: string | null; licenseNo?: string | null; licenseIssueDate?: Date | null; licenseExpireDate?: Date | null; sourceNationality?: string | null; importNationality?: string | null; trainingPlace?: string | null; responsibleArea?: string | null; birthDate?: Date | null; address: string; addressEN: string; soi?: string | null; soiEN?: string | null; moo?: string | null; mooEN?: string | null; street?: string | null; streetEN?: string | null; email: string; telephoneNo: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; selectedImage?: string; branchId: string | string[]; }; type UserUpdate = { status?: "ACTIVE" | "INACTIVE"; username?: string; userType?: UserType; userRole?: string; citizenId?: string; citizenIssue?: Date; citizenExpire?: Date | null; namePrefix?: string | null; firstName?: string; firstNameEN?: string; middleName?: string | null; middleNameEN?: string | null; lastName?: string; lastNameEN?: string; gender?: string; checkpoint?: string | null; checkpointEN?: string | null; registrationNo?: string | null; startDate?: Date | null; retireDate?: Date | null; discountCondition?: string | null; licenseNo?: string | null; licenseIssueDate?: Date | null; licenseExpireDate?: Date | null; sourceNationality?: string | null; importNationality?: string | null; trainingPlace?: string | null; responsibleArea?: string | null; birthDate?: Date | null; address?: string; addressEN?: string; soi?: string | null; soiEN?: string | null; moo?: string | null; mooEN?: string | null; street?: string | null; streetEN?: string | null; email?: string; telephoneNo?: string; selectedImage?: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; branchId?: string | string[]; }; const permissionCond = createPermCondition(globalAllow); const permissionCheck = createPermCheck(globalAllow); async function userBranchCodeGen(user: User, branch: Branch) { return await prisma.$transaction( async (tx) => { const typ = user.userType; const mapTypeNo = { USER: 1, MESSENGER: 2, DELEGATE: 3, AGENCY: 4, }[typ]; const last = await tx.runningNo.upsert({ where: { key: `BR_USR_${branch.code}_${mapTypeNo}`, }, create: { key: `BR_USR_${branch.code}_${mapTypeNo}`, value: 1, }, update: { value: { increment: 1 } }, }); return await tx.user.update({ where: { id: user.id }, data: { code: mapTypeNo + `${last.value}`.padStart(6, "0"), }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); } @Route("api/v1/user") @Tags("User") export class UserController extends Controller { @Get("type-stats") @Security("keycloak") async getUserTypeStats(@Request() req: RequestWithUser) { const list = await prisma.user.groupBy({ by: "userType", _count: true, where: { userRole: { not: "system" }, branch: isSystem(req.user) ? undefined : { some: { branch: { OR: permissionCond(req.user), }, }, }, }, }); return list.reduce>( (a, c) => { a[c.userType] = c._count; return a; }, { USER: 0, MESSENGER: 0, DELEGATE: 0, AGENCY: 0, }, ); } @Get() @Security("keycloak") async getUser( @Request() req: RequestWithUser, @Query() userType?: UserType, @Query() includeBranch: boolean = false, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, @Query() status?: Status, ) { const where = { OR: [ { firstName: { contains: query } }, { firstNameEN: { contains: query } }, { lastName: { contains: query } }, { lastNameEN: { contains: query } }, { email: { contains: query } }, { telephoneNo: { contains: query } }, ...whereAddressQuery(query), ], AND: { userRole: { not: "system" }, userType, ...filterStatus(status), branch: isSystem(req.user) ? undefined : { some: { branch: { OR: permissionCond(req.user), }, }, }, }, } satisfies Prisma.UserWhereInput; const [result, total] = await prisma.$transaction([ prisma.user.findMany({ orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], include: { province: true, district: true, subDistrict: true, branch: { include: { branch: includeBranch } }, createdBy: true, updatedBy: true, }, where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.user.count({ where }), ]); return { result: result.map((v) => ({ ...v, branch: includeBranch ? v.branch.map((a) => a.branch) : undefined, })), page, pageSize, total, }; } @Get("{userId}") @Security("keycloak") async getUserById(@Path() userId: string) { const record = await prisma.user.findFirst({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where: { id: userId }, }); if (!record) throw notFoundError("User"); return record; } @Post() @Security("keycloak", MANAGE_ROLES) async createUser(@Request() req: RequestWithUser, @Body() body: UserCreate) { const [province, district, subDistrict, branch, user] = 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.findMany({ include: branchRelationPermInclude(req.user), where: { id: { in: Array.isArray(body.branchId) ? body.branchId : [body.branchId] } }, }), prisma.user.findFirst({ where: { OR: [{ username: body.username }, { email: body.email }] }, }), ]); if (body.provinceId && !province) throw relationError("Province"); if (body.districtId && !district) throw relationError("District"); if (body.subDistrictId && !subDistrict) throw relationError("SubDistrict"); if (branch.length === 0) { throw new HttpError( HttpStatus.BAD_REQUEST, "Require at least one branch for a user.", "minimumBranchNotMet", ); } await Promise.all(branch.map((branch) => permissionCheck(req.user, branch))); if (user && user.username === body.username) { throw new HttpError(HttpStatus.BAD_REQUEST, "User exists.", "userExistsSameUserName"); } if (user && user.email === body.email) { throw new HttpError(HttpStatus.BAD_REQUEST, "User exists.", "userExistsSameEmail"); } const setRoleIndex = MANAGE_ROLES.findIndex((v) => v === body.userRole); const userRoleIndex = MANAGE_ROLES.reduce( (a, c, i) => (req.user.roles?.includes(c) ? i : a), -1, ); const THROW_PERM_MSG = "You do not have permission to perform this action."; const THROW_PERM_CODE = "noPermission"; if (setRoleIndex !== -1 && setRoleIndex < userRoleIndex) { throw new HttpError(HttpStatus.FORBIDDEN, THROW_PERM_MSG, THROW_PERM_CODE); } if (!globalAllow(req.user)) { if (branch.some((v) => !v.user.find((v) => v.userId === req.user.sub))) { throw new HttpError(HttpStatus.FORBIDDEN, THROW_PERM_MSG, THROW_PERM_CODE); } } const { branchId, provinceId, districtId, subDistrictId, username, ...rest } = body; let list = await listRole(); if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); if (Array.isArray(list)) { list = list.filter( (a) => !["uma_authorization", "offline_access", "default-roles"].some((b) => a.name.includes(b)), ); } const userId = await createUser(username, username, { firstName: body.firstName, lastName: body.lastName, email: body.email, requiredActions: ["UPDATE_PASSWORD"], enabled: rest.status !== "INACTIVE", }); if (!userId || typeof userId !== "string") { throw new Error("Cannot create user with keycloak service."); } const role = list.find((v) => v.name === body.userRole); const resultAddRole = role && (await addUserRoles(userId, [role])); if (!resultAddRole) { await deleteUser(userId); throw new Error("Failed. Cannot set user's role."); } const record = await prisma.user.create({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, data: { id: userId, ...rest, statusOrder: +(rest.status === "INACTIVE"), username, userRole: role.name, province: connectOrNot(provinceId), district: connectOrNot(districtId), subDistrict: connectOrNot(subDistrictId), branch: { create: Array.isArray(branchId) ? branchId.map((v) => ({ branchId: v, createdByUserId: req.user.sub, udatedByUserId: req.user.sub, })) : { branchId, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, }, }, createdBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } }, }, }); const updated = await userBranchCodeGen(record, branch[0]); // only generate code by using first branch only record.code = updated.code; this.setStatus(HttpStatus.CREATED); return record; } @Put("{userId}") @Security("keycloak", MANAGE_ROLES) async editUser( @Request() req: RequestWithUser, @Body() body: UserUpdate, @Path() userId: string, ) { const [province, district, subDistrict, user, branch, conflictUser] = 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.user.findFirst({ include: { branch: { include: { branch: { include: branchRelationPermInclude(req.user), }, }, }, }, where: { id: userId }, }), prisma.branch.findMany({ include: branchRelationPermInclude(req.user), where: { id: { in: Array.isArray(body.branchId) ? body.branchId : body.branchId ? [body.branchId] : [], }, }, }), prisma.user.findFirst({ where: { OR: [{ username: body.username }, { email: body.email }], NOT: { id: userId } }, }), ], ); if (!user) throw notFoundError("User"); if (body.provinceId && !province) throw relationError("Province"); if (body.districtId && !district) throw relationError("District"); if (body.subDistrictId && !subDistrict) throw relationError("SubDistrict"); if (body.branchId && branch.length === 0) { throw new HttpError( HttpStatus.BAD_REQUEST, "Require at least one branch for a user.", "minimumBranchNotMet", ); } if (conflictUser && conflictUser.username === body.username) { throw new HttpError(HttpStatus.BAD_REQUEST, "User exists.", "userExistsSameUserName"); } if (conflictUser && conflictUser.email === body.email) { throw new HttpError(HttpStatus.BAD_REQUEST, "User exists.", "userExistsSameEmail"); } await Promise.all([ ...user.branch.map(async ({ branch }) => await permissionCheck(req.user, branch)), ...branch.map(async (branch) => await permissionCheck(req.user, branch)), ]); const setRoleIndex = MANAGE_ROLES.findIndex((v) => v === body.userRole); const userRoleIndex = MANAGE_ROLES.reduce( (a, c, i) => (req.user.roles?.includes(c) ? i : a), -1, ); const THROW_PERM_MSG = "You do not have permission to perform this action."; const THROW_PERM_CODE = "noPermission"; if (setRoleIndex !== -1 && setRoleIndex < userRoleIndex) { throw new HttpError(HttpStatus.FORBIDDEN, THROW_PERM_MSG, THROW_PERM_CODE); } if (!globalAllow(req.user) && body.branchId) { if (branch.some((v) => !v.user.find((v) => v.userId === req.user.sub))) { throw new HttpError(HttpStatus.FORBIDDEN, THROW_PERM_MSG, THROW_PERM_CODE); } } let userRole: string | undefined; if (body.userRole && user.userRole !== body.userRole) { let list = await listRole(); if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); if (Array.isArray(list)) { list = list.filter( (a) => !["uma_authorization", "offline_access", "default-roles"].some((b) => a.name.includes(b), ), ); } const currentRole = await getUserRoles(userId); const role = list.find((v) => v.name === body.userRole); if (role) { const resultAddRole = await addUserRoles(userId, [role]); if (!resultAddRole) { throw new Error("Failed. Cannot set user's role."); } else { if (Array.isArray(currentRole)) await removeUserRoles( userId, currentRole.filter( (a) => !["uma_authorization", "offline_access", "default-roles"].some((b) => a.name.includes(b), ), ), ); } userRole = role.name; } } if (body.username) { await editUser(userId, { username: body.username, email: body.email, enabled: body.status !== "INACTIVE", }); } const { provinceId, districtId, subDistrictId, branchId, ...rest } = body; const record = await prisma.user.update({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), userRole, province: connectOrDisconnect(provinceId), district: connectOrDisconnect(districtId), subDistrict: connectOrDisconnect(subDistrictId), updatedBy: { connect: { id: req.user.sub } }, }, where: { id: userId }, }); if (branchId) { await prisma.$transaction([ prisma.branchUser.deleteMany({ where: { userId, branchId: { not: { in: Array.isArray(branchId) ? branchId : [branchId] } }, }, }), prisma.branchUser.createMany({ data: (Array.isArray(branchId) ? branchId : [branchId]) .filter((a) => !user.branch.some((b) => a === b.branchId)) .map((v) => ({ userId, branchId: v, })), }), prisma.branch.updateMany({ where: { id: { in: Array.isArray(branchId) ? branchId : [branchId] }, status: "CREATED", }, data: { status: "ACTIVE" }, }), ]); if (branch[0]?.id !== user.branch[0]?.branchId) { const updated = await userBranchCodeGen(user, branch[0]); record.code = updated.code; } } return record; } @Delete("{userId}") @Security("keycloak", MANAGE_ROLES) async deleteUser(@Request() req: RequestWithUser, @Path() userId: string) { const record = await prisma.user.findFirst({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, branch: { include: { branch: { include: branchRelationPermInclude(req.user), }, }, }, }, where: { id: userId }, }); if (!record) throw notFoundError("User"); await Promise.all([ ...record.branch.map(async ({ branch }) => await permissionCheck(req.user, branch)), ]); if (record.status !== Status.CREATED) throw isUsedError("User"); await deleteFolder(fileLocation.user.profile(userId)); await deleteFolder(fileLocation.user.attachment(userId)); await deleteUser(userId); return await prisma.user.delete({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where: { id: userId }, }); } } async function getUserCheckPerm(user: RequestWithUser["user"], userId: string) { const record = await prisma.user.findFirst({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, branch: { include: { branch: { include: branchRelationPermInclude(user), }, }, }, }, where: { id: userId }, }); if (!record) throw notFoundError("User"); await Promise.all(record.branch.map(async ({ branch }) => await permissionCheck(user, branch))); } @Route("api/v1/user/{userId}/profile-image") @Tags("User") export class UserProfileController extends Controller { @Get() @Security("keycloak") async listImage(@Path() userId: string) { const record = await prisma.user.findFirst({ where: { id: userId }, }); if (!record) throw notFoundError("User"); return await listFile(fileLocation.user.profile(userId)); } @Get("{name}") async getImage(@Request() req: RequestWithUser, @Path() userId: string, @Path() name: string) { return req.res?.redirect(await getFile(fileLocation.user.profile(userId, name))); } @Put("{name}") @Security("keycloak") async putImage(@Request() req: RequestWithUser, @Path() userId: string, @Path() name: string) { await getUserCheckPerm(req.user, userId); return req.res?.redirect(await setFile(fileLocation.user.profile(userId, name))); } @Delete("{name}") @Security("keycloak") async deleteImage(@Request() req: RequestWithUser, @Path() userId: string, @Path() name: string) { await getUserCheckPerm(req.user, userId); await deleteFile(fileLocation.user.profile(userId, name)); } } @Route("api/v1/user/{userId}/attachment") @Tags("User") @Security("keycloak") export class UserAttachmentController extends Controller { @Get() async listAttachment(@Path() userId: string) { const record = await prisma.user.findFirst({ where: { id: userId }, }); if (!record) throw notFoundError("User"); const list = await listFile(fileLocation.user.attachment(userId)); return await Promise.all( list.map(async (v) => ({ name: v, url: await getFile(fileLocation.user.attachment(userId, v)), })), ); } @Post() async addAttachment( @Request() req: RequestWithUser, @Path() userId: string, @Body() payload: { file: string[] }, ) { await getUserCheckPerm(req.user, userId); return await Promise.all( payload.file.map(async (v) => ({ name: v, url: await getFile(fileLocation.user.attachment(userId, v)), uploadUrl: await setFile(fileLocation.user.attachment(userId, v), 12 * 60 * 60), })), ); } @Delete() async deleteAttachment( @Request() req: RequestWithUser, @Path() userId: string, @Body() payload: { file: string[] }, ) { await getUserCheckPerm(req.user, userId); await Promise.all( payload.file.map(async (v) => { await deleteFile(fileLocation.user.attachment(userId, v)); }), ); } }