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 minio, { presignedGetObjectIfExist } from "../services/minio"; 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"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MINIO_BUCKET = process.env.MINIO_BUCKET; type UserCreate = { status?: Status; userType: UserType; userRole: string; username: string; firstName: string; firstNameEN: string; 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; zipCode: string; email: string; telephoneNo: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; branchId: string | string[]; }; type UserUpdate = { status?: "ACTIVE" | "INACTIVE"; username?: string; userType?: UserType; userRole?: string; firstName?: string; firstNameEN?: string; 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; zipCode?: string; email?: string; telephoneNo?: string; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; branchId?: string | string[]; }; 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, "9"), }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); } function imageLocation(id: string) { return `user/profile-img-${id}`; } @Route("api/v1/user") @Tags("User") export class UserController extends Controller { @Get("type-stats") @Security("keycloak") async getUserTypeStats() { const list = await prisma.user.groupBy({ by: "userType", _count: true, }); 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( @Query() userType?: UserType, @Query() zipCode?: string, @Query() includeBranch: boolean = false, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { const where = { OR: [ { firstName: { contains: query }, zipCode, userType }, { firstNameEN: { contains: query }, zipCode, userType }, { lastName: { contains: query }, zipCode, userType }, { lastNameEN: { contains: query }, zipCode, userType }, { email: { contains: query }, zipCode, userType }, { telephoneNo: { contains: query }, zipCode, userType }, ], AND: { userRole: { not: "system" }, }, } 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: await Promise.all( result.map(async (v) => ({ ...v, branch: includeBranch ? v.branch.map((a) => a.branch) : undefined, profileImageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(v.id), 12 * 60 * 60, ), })), ), 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 new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "userNotFound"); return Object.assign(record, { profileImageUrl: await minio.presignedGetObject( MINIO_BUCKET, imageLocation(record.id), 60 * 60, ), }); } @Post() @Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin"]) async createUser(@Request() req: RequestWithUser, @Body() body: UserCreate) { 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.findMany({ include: { user: { where: { userId: req.user.sub } } }, where: { id: { in: Array.isArray(body.branchId) ? body.branchId : [body.branchId] } }, }), ]); 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 (branch.length === 0) { throw new HttpError( HttpStatus.BAD_REQUEST, "Require at least one branch for a user.", "minimumBranchNotMet", ); } if ( !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) && branch?.some((v) => !v.user.find((v) => v.userId === req.user.sub)) ) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } 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, 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: { connect: provinceId ? { id: provinceId } : undefined }, district: { connect: districtId ? { id: districtId } : undefined }, subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined }, createdBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } }, }, }); await prisma.branchUser.createMany({ data: Array.isArray(branchId) ? branchId.map((v) => ({ branchId: v, userId: record.id, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, })) : { branchId, userId: record.id, createdByUserId: req.user.sub, updatedByUserId: 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 Object.assign(record, { profileImageUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), profileImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Put("{userId}") @Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"]) 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: true }, where: { id: userId }, }), prisma.branch.findMany({ include: { user: { where: { id: req.user.sub } } }, where: { id: { in: Array.isArray(body.branchId) ? body.branchId : body.branchId ? [body.branchId] : [], }, }, }), ]); if (!user) { throw new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "userNotFound"); } if (body.provinceId && !province) throw new HttpError( HttpStatus.BAD_REQUEST, "Province cannot be found.", "missing_or_invalid_parameter", ); if (body.districtId && !district) throw new HttpError( HttpStatus.BAD_REQUEST, "District cannot be found.", "missing_or_invalid_parameter", ); if (body.subDistrictId && !subDistrict) throw new HttpError( HttpStatus.BAD_REQUEST, "Sub-district cannot be found.", "missing_or_invalid_parameter", ); if (body.branchId && branch.length === 0) { throw new HttpError( HttpStatus.BAD_REQUEST, "Require at least one branch for a user.", "minimumBranchNotMet", ); } if ( !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) && branch?.some((v) => !v.user.find((v) => v.userId === req.user.sub)) ) { throw new HttpError( HttpStatus.FORBIDDEN, "You do not have permission to perform this action.", "noPermission", ); } let userRole: string | undefined; if (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); const resultAddRole = role && (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, 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: { 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, }, 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, })), }), ]); if (branch[0]?.id !== user.branch[0]?.id) { const updated = await userBranchCodeGen(user, branch[0]); record.code = updated.code; } } return Object.assign(record, { profileImageUrl: await minio.presignedGetObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), profileImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Delete("{userId}") @Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"]) 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: { where: { userId: req.user.sub, }, }, }, where: { id: userId }, }); if ( !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) && !record?.branch.some((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, "User cannot be found.", "userNotFound"); } if (record.status !== Status.CREATED) { throw new HttpError(HttpStatus.FORBIDDEN, "User is in used.", "userInUsed"); } await minio.removeObject(MINIO_BUCKET, imageLocation(userId), { forceDelete: true, }); new Promise((resolve, reject) => { const item: string[] = []; const stream = minio.listObjectsV2(MINIO_BUCKET, `${attachmentLocation(userId)}/`); stream.on("data", (v) => v && v.name && item.push(v.name)); stream.on("end", () => resolve(item)); stream.on("error", () => reject(new Error("MinIO error."))); }).then((list) => { list.map(async (v) => { await minio.removeObject(MINIO_BUCKET, v, { forceDelete: true }); }); }); await deleteUser(userId); return await prisma.user.delete({ include: { province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where: { id: userId }, }); } @Get("{userId}/image") async getUserImageByUserId(@Request() req: RequestWithUser, @Path() userId: string) { const url = await presignedGetObjectIfExist(MINIO_BUCKET, imageLocation(userId), 60 * 60); if (!url) { throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound"); } return req.res?.redirect(url); } @Put("{userId}/image") @Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"]) async setUserImageByUserId(@Request() req: RequestWithUser, @Path() userId: string) { const record = await prisma.user.findFirst({ include: { branch: { where: { userId: req.user.sub } }, }, where: { id: userId, }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "userNotFound"); } if ( !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) && !record.branch.some((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, imageLocation(userId), 12 * 60 * 60), ); } } function attachmentLocation(uid: string) { return `user-attachment/${uid}`; } @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 new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "userNotFound"); } const list = await new Promise((resolve, reject) => { const item: string[] = []; const stream = minio.listObjectsV2(MINIO_BUCKET, `${attachmentLocation(userId)}/`); stream.on("data", (v) => v && v.name && item.push(v.name)); stream.on("end", () => resolve(item)); stream.on("error", () => reject(new Error("MinIO error."))); }); return await Promise.all( list.map(async (v) => ({ name: v.split("/").at(-1) as string, url: await minio.presignedGetObject(MINIO_BUCKET, v, 12 * 60 * 60), })), ); } @Post() async addAttachment(@Path() userId: string, @Body() payload: { file: string[] }) { const record = await prisma.user.findFirst({ where: { id: userId }, }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "userNotFound"); } return await Promise.all( payload.file.map(async (v) => ({ name: v, url: await minio.presignedGetObject(MINIO_BUCKET, `${attachmentLocation(userId)}/${v}`), uploadUrl: await minio.presignedPutObject( MINIO_BUCKET, `${attachmentLocation(userId)}/${v}`, 12 * 60 * 60, ), })), ); } @Delete() async deleteAttachment(@Path() userId: string, @Body() payload: { file: string[] }) { await Promise.all( payload.file.map(async (v) => { await minio.removeObject(MINIO_BUCKET, `${attachmentLocation(userId)}/${v}`, { forceDelete: true, }); }), ); } }