import { Body, Controller, Delete, Get, Put, Path, Post, Query, Request, Route, Security, Tags, Head, } 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, getGroupUser, } from "../services/keycloak"; import { isSystem } from "../utils/keycloak"; import { deleteFile, deleteFolder, fileLocation, getFile, getPresigned, listFile, setFile, uploadFile, } from "../utils/minio"; import { filterStatus } from "../services/prisma"; import { branchActiveOnlyCond, branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; import { connectOrDisconnect, connectOrNot, queryOrNot, whereAddressQuery, whereDateQuery, } from "../utils/relation"; import { isUsedError, notFoundError, relationError } from "../utils/error"; import { retry } from "../utils/func"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "executive", "branch_admin", "branch_manager", ]; function globalAllow(user: RequestWithUser["user"]) { const listAllowed = ["system", "head_of_admin", "admin", "executive"]; 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; addressForeign?: boolean; 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; subDistrictText?: string | null; subDistrictId?: string | null; districtText?: string | null; districtId?: string | null; provinceText?: string | null; provinceId?: string | null; selectedImage?: string; branchId: string | string[]; remark?: string; agencyStatus?: string; contactName?: string | null; contactTel?: string | null; }; 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; addressForeign?: boolean; 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; subDistrictText?: string | null; subDistrictId?: string | null; districtText?: string | null; districtId?: string | null; provinceText?: string | null; provinceId?: string | null; branchId?: string | string[]; remark?: string; agencyStatus?: string; contactName?: string | null; contactTel?: string | null; }; const permissionCondCompany = createPermCondition((_) => true); const permissionCond = createPermCondition(globalAllow); const permissionCheck = createPermCheck(globalAllow); async function userBranchCodeGen(user: User, branch: Branch) { return await retry(() => 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, @Query() responsibleDistrictId?: string, @Query() activeBranchOnly?: boolean, @Query() startDate?: Date, @Query() endDate?: Date, ) { return this.getUserByCriteria( req, userType, includeBranch, query, page, pageSize, status, responsibleDistrictId, activeBranchOnly, startDate, endDate, ); } @Post("list") @Security("keycloak") async getUserByCriteria( @Request() req: RequestWithUser, @Query() userType?: UserType, @Query() includeBranch: boolean = false, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, @Query() status?: Status, @Query() responsibleDistrictId?: string, @Query() activeBranchOnly?: boolean, @Query() startDate?: Date, @Query() endDate?: Date, @Body() body?: { userId?: string[]; }, ) { const area = responsibleDistrictId ? await prisma.employmentOffice.findMany({ where: { OR: [ { province: { district: { some: { id: responsibleDistrictId } } }, district: { none: {} }, }, { district: { some: { districtId: responsibleDistrictId }, }, }, ], }, }) : null; const where = { OR: queryOrNot(query, [ { code: { contains: query, mode: "insensitive" } }, { firstName: { contains: query, mode: "insensitive" } }, { firstNameEN: { contains: query, mode: "insensitive" } }, { lastName: { contains: query, mode: "insensitive" } }, { lastNameEN: { contains: query, mode: "insensitive" } }, { email: { contains: query, mode: "insensitive" } }, { telephoneNo: { contains: query, mode: "insensitive" } }, ...whereAddressQuery(query), ]), AND: { OR: [ { responsibleArea: area ? { some: { area: { in: area.map((v) => v.id) } }, } : undefined, }, { id: body?.userId ? { in: body.userId } : undefined, }, ], userRole: { not: "system" }, userType, ...filterStatus(status), branch: isSystem(req.user) ? activeBranchOnly ? { some: { branch: branchActiveOnlyCond(activeBranchOnly) } } : undefined : { some: { branch: { OR: responsibleDistrictId ? permissionCondCompany(req.user, { activeOnly: activeBranchOnly }) // NOTE: when pass responsibleDistrictId should see all user not only to current branch : permissionCond(req.user, { activeOnly: activeBranchOnly }), }, }, }, }, ...whereDateQuery(startDate, endDate), } satisfies Prisma.UserWhereInput; const [result, total] = await prisma.$transaction([ prisma.user.findMany({ orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], include: { importNationality: true, responsibleArea: true, 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, importNationality: v.importNationality.map((v) => v.name), responsibleArea: v.responsibleArea.map((v) => v.area), 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: { importNationality: true, province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where: { id: userId }, }); if (!record) throw notFoundError("User"); const { importNationality, ...rest } = record; return Object.assign(rest, { importNationality: importNationality.map((v) => v.name), }); } @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: { username: body.username }, }), ]); 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) { throw new HttpError(HttpStatus.BAD_REQUEST, "User exists.", "userExists"); } 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.firstNameEN, lastName: body.lastNameEN, 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, responsibleArea: rest.responsibleArea ? { create: rest.responsibleArea.map((v) => ({ area: v })), } : undefined, importNationality: { createMany: { data: rest.importNationality?.map((v) => ({ name: v })) || [] }, }, 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, updatedByUserId: 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] = 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] : [], }, }, }), ]); 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", ); } 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 || body.email || body.firstName || body.lastName) { await editUser(userId, { firstName: body.firstName, lastName: body.lastName, username: body.username, email: body.email, enabled: body.status ? body.status !== "INACTIVE" : undefined, }); } else if (body.status) { await editUser(userId, { enabled: body.status !== "INACTIVE" }); } const { provinceId, districtId, subDistrictId, branchId, ...rest } = body; const record = await prisma.user.update({ include: { importNationality: true, province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, data: { ...rest, responsibleArea: rest.responsibleArea ? { deleteMany: {}, create: rest.responsibleArea.map((v) => ({ area: v })), } : undefined, importNationality: { deleteMany: {}, createMany: { data: rest.importNationality?.map((v) => ({ name: v })) || [] }, }, 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))); } @Head("{name}") async headImage(@Request() req: RequestWithUser, @Path() userId: string, @Path() name: string) { return req.res?.redirect(await getPresigned("head", 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)); }), ); } } @Route("api/v1/user/{userId}/signature") @Security("keycloak") export class UserSignatureController extends Controller { #checkPermission(req: RequestWithUser, userId: string) { if (req.user.sub !== userId) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } } @Get() async getSignature(@Request() req: RequestWithUser, @Path() userId: string) { this.#checkPermission(req, userId); return await getFile(fileLocation.user.signature(userId)); } @Put() async setSignature( @Request() req: RequestWithUser, @Path() userId: string, @Body() signature?: { data: string }, ) { this.#checkPermission(req, userId); const base64 = signature?.data; if (base64) { const buffer = Buffer.from(base64.replace(/^data:image\/\w+;base64,/, ""), "base64"); const mime = "image/" + base64.split(";")[0].split("/")[1]; await uploadFile(fileLocation.user.signature(userId), buffer, mime); } else { return await setFile(fileLocation.user.signature(userId)); } } @Delete() async deleteSignature(@Request() req: RequestWithUser, @Path() userId: string) { this.#checkPermission(req, userId); await deleteFile(fileLocation.user.signature(userId)); } } @Route("api/v1/user/{userId}/group") @Tags("User") @Security("keycloak") export class UserGroupController extends Controller { @Get() async getUserGroup(@Path() userId: string) { const groupUser = await getGroupUser(userId); if (!Array.isArray(groupUser)) throw new Error("Failed. Cannot get user group(s) data from the server."); return groupUser; } }