import { Prisma, Status } from "@prisma/client"; import { Body, Controller, Delete, Get, Path, Post, Put, Query, Request, Route, Security, Tags, } from "tsoa"; import { RequestWithUser } from "../interfaces/user"; import prisma from "../db"; import HttpStatus from "../interfaces/http-status"; import HttpError from "../interfaces/http-error"; import { isSystem } from "../utils/keycloak"; import { filterStatus } from "../services/prisma"; import { branchRelationPermInclude, createPermCheck, createPermCondition, } from "../services/permission"; import { connectOrDisconnect, connectOrNot, whereAddressQuery } from "../utils/relation"; import { notFoundError, relationError } from "../utils/error"; import { deleteFile, fileLocation, getFile, listFile, setFile } from "../utils/minio"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "head_of_account", "account", "head_of_sale", ]; function globalAllow(user: RequestWithUser["user"]) { const allowList = ["system", "head_of_admin", "admin", "head_of_account", "head_of_sale"]; return allowList.some((v) => user.roles?.includes(v)); } const permissionCond = createPermCondition(globalAllow); const permissionCheck = createPermCheck(globalAllow); type EmployeeCreate = { customerBranchId: string; status?: Status; nrcNo?: string; dateOfBirth: Date; gender: string; nationality: string; namePrefix?: string | null; firstName: string; firstNameEN: string; middleName?: string | null; middleNameEN?: string | null; lastName: string; lastNameEN: string; addressEN: string; address: string; soi?: string | null; soiEN?: string | null; moo?: string | null; mooEN?: string | null; street?: string | null; streetEN?: string | null; passportType: string; passportNumber: string; passportIssueDate: Date; passportExpiryDate: Date; passportIssuingCountry: string; passportIssuingPlace: string; previousPassportReference?: string; visaType?: string | null; visaNumber?: string | null; visaIssueDate?: Date | null; visaExpiryDate?: Date | null; visaIssuingPlace?: string | null; visaStayUntilDate?: Date | null; tm6Number?: string | null; entryDate?: Date | null; workerStatus?: string | null; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; selectedImage?: string | null; employeeWork?: { ownerName?: string | null; positionName?: string | null; jobType?: string | null; workplace?: string | null; workPermitNo?: string | null; workPermitIssuDate?: Date | null; workPermitExpireDate?: Date | null; workEndDate?: Date | null; remark?: string | null; }[]; employeeCheckup?: { checkupType?: string | null; checkupResult?: string | null; provinceId?: string | null; hospitalName?: string | null; remark?: string | null; medicalBenefitScheme?: string | null; insuranceCompany?: string | null; coverageStartDate?: Date | null; coverageExpireDate?: Date | null; }[]; employeeOtherInfo?: { citizenId?: string | null; fatherFirstName?: string | null; fatherLastName?: string | null; fatherBirthPlace?: string | null; motherFirstName?: string | null; motherLastName?: string | null; motherBirthPlace?: string | null; fatherFirstNameEN?: string | null; fatherLastNameEN?: string | null; motherFirstNameEN?: string | null; motherLastNameEN?: string | null; }; }; type EmployeeUpdate = { customerBranchId?: string; status?: "ACTIVE" | "INACTIVE"; nrcNo?: string; dateOfBirth?: Date; gender?: string; nationality?: string; namePrefix?: string | null; firstName?: string; firstNameEN?: string; middleName?: string | null; middleNameEN?: string | null; lastName?: string; lastNameEN?: string; addressEN?: string; address?: string; soi?: string | null; soiEN?: string | null; moo?: string | null; mooEN?: string | null; street?: string | null; streetEN?: string | null; passportType?: string; passportNumber?: string; passportIssueDate?: Date; passportExpiryDate?: Date; passportIssuingCountry?: string; passportIssuingPlace?: string; previousPassportReference?: string; visaType?: string | null; visaNumber?: string | null; visaIssueDate?: Date | null; visaExpiryDate?: Date | null; visaIssuingPlace?: string | null; visaStayUntilDate?: Date | null; tm6Number?: string | null; entryDate?: Date | null; workerStatus?: string | null; selectedImage?: string | null; subDistrictId?: string | null; districtId?: string | null; provinceId?: string | null; employeeWork?: { id?: string; ownerName?: string | null; positionName?: string | null; jobType?: string | null; workplace?: string | null; workPermitNo?: string | null; workPermitIssuDate?: Date | null; workPermitExpireDate?: Date | null; workEndDate?: Date | null; remark?: string | null; }[]; employeeCheckup?: { id?: string; checkupType?: string | null; checkupResult?: string | null; provinceId?: string | null; hospitalName?: string | null; remark?: string | null; medicalBenefitScheme?: string | null; insuranceCompany?: string | null; coverageStartDate?: Date | null; coverageExpireDate?: Date | null; }[]; employeeOtherInfo?: { citizenId?: string | null; fatherFirstName?: string | null; fatherLastName?: string | null; fatherBirthPlace?: string | null; motherFirstName?: string | null; motherLastName?: string | null; motherBirthPlace?: string | null; fatherFirstNameEN?: string | null; fatherLastNameEN?: string | null; motherFirstNameEN?: string | null; motherLastNameEN?: string | null; }; }; @Route("api/v1/employee") @Tags("Employee") export class EmployeeController extends Controller { @Get("stats") @Security("keycloak") async getEmployeeStats(@Query() customerBranchId?: string) { return await prisma.employee.count({ where: { customerBranchId }, }); } @Get("stats/gender") @Security("keycloak") async getEmployeeStatsGender( @Request() req: RequestWithUser, @Query() customerBranchId?: string, @Query() status?: Status, @Query() query: string = "", ) { return await prisma.employee .groupBy({ _count: true, by: ["gender"], where: { OR: [ { firstName: { contains: query } }, { firstNameEN: { contains: query } }, { lastName: { contains: query } }, { lastNameEN: { contains: query } }, ...whereAddressQuery(query), ], AND: { ...filterStatus(status), customerBranchId, customerBranch: { customer: isSystem(req.user) ? undefined : { registeredBranch: { OR: permissionCond(req.user) }, }, }, }, }, }) .then((res) => res.reduce>((a, c) => { a[c.gender] = c._count; return a; }, {}), ); } @Get() @Security("keycloak") async list( @Request() req: RequestWithUser, @Query() zipCode?: string, @Query() gender?: string, @Query() status?: Status, @Query() customerId?: string, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { const where = { OR: [ { firstName: { contains: query } }, { firstNameEN: { contains: query } }, { lastName: { contains: query } }, { lastNameEN: { contains: query } }, { passportNumber: { contains: query } }, ...whereAddressQuery(query), ], AND: { ...filterStatus(status), customerBranch: { customerId, customer: isSystem(req.user) ? undefined : { registeredBranch: { OR: permissionCond(req.user), }, }, }, subDistrict: zipCode ? { zipCode } : undefined, gender, }, } satisfies Prisma.EmployeeWhereInput; const [result, total] = await prisma.$transaction([ prisma.employee.findMany({ orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], include: { province: true, district: true, subDistrict: true, customerBranch: { include: { customer: true }, }, createdBy: true, updatedBy: true, }, where, take: pageSize, skip: (page - 1) * pageSize, }), prisma.employee.count({ where }), ]); return { result, page, pageSize, total, }; } @Get("{employeeId}") @Security("keycloak") async getById(@Path() employeeId: string) { const record = await prisma.employee.findFirst({ include: { employeeWork: true, employeeCheckup: true, employeeOtherInfo: true, province: true, district: true, subDistrict: true, createdBy: true, updatedBy: true, }, where: { id: employeeId }, }); if (!record) throw notFoundError("Employee"); return record; } @Post() @Security("keycloak", MANAGE_ROLES) async create(@Request() req: RequestWithUser, @Body() body: EmployeeCreate) { const [province, district, subDistrict, customerBranch] = 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.customerBranch.findFirst({ where: { id: body.customerBranchId }, include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, }), ]); if (body.provinceId !== province?.id) throw relationError("Province"); if (body.districtId !== district?.id) throw relationError("District"); if (body.subDistrictId !== subDistrict?.id) throw relationError("SubDistrict"); if (!customerBranch) throw relationError("Customer Branch"); await permissionCheck(req.user, customerBranch.customer.registeredBranch); const { provinceId, districtId, subDistrictId, customerBranchId, employeeWork, employeeCheckup, employeeOtherInfo, ...rest } = body; const listProvinceId = employeeCheckup?.reduce((acc, cur) => { if (cur.provinceId && !acc.includes(cur.provinceId)) return acc.concat(cur.provinceId); if (!cur.provinceId) cur.provinceId = null; return acc; }, []); if (listProvinceId) { const [listProvince] = await prisma.$transaction([ prisma.province.findMany({ where: { id: { in: listProvinceId } } }), ]); if (listProvince.length !== listProvinceId.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some province cannot be found.", "someProvinceNotFound", ); } } const record = await prisma.$transaction( async (tx) => { const last = await tx.runningNo.upsert({ where: { key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, }, create: { key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, value: 1, }, update: { value: { increment: 1 } }, }); return await prisma.employee.create({ include: { province: true, district: true, subDistrict: true, employeeOtherInfo: true, employeeCheckup: { include: { province: true, }, }, employeeWork: true, createdBy: true, updatedBy: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), code: `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${last.value}`.padStart(7, "0")}`, employeeWork: { createMany: { data: employeeWork || [], }, }, employeeCheckup: { createMany: { data: employeeCheckup?.map((v) => ({ ...v, provinceId: !!v.provinceId ? null : v.provinceId, })) || [], }, }, employeeOtherInfo: { create: employeeOtherInfo, }, province: connectOrNot(provinceId), district: connectOrNot(districtId), subDistrict: connectOrNot(subDistrictId), customerBranch: { connect: { id: customerBranchId } }, createdBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } }, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); await prisma.customerBranch.updateMany({ where: { id: customerBranchId, status: Status.CREATED }, data: { status: Status.ACTIVE }, }); await prisma.customer.updateMany({ where: { branch: { some: { id: customerBranchId }, }, status: Status.CREATED, }, data: { status: Status.ACTIVE }, }); this.setStatus(HttpStatus.CREATED); return record; } @Put("{employeeId}") @Security("keycloak", MANAGE_ROLES) async editById( @Request() req: RequestWithUser, @Body() body: EmployeeUpdate, @Path() employeeId: string, ) { const [province, district, subDistrict, customerBranch, employee] = 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.customerBranch.findFirst({ where: { id: body.customerBranchId }, include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, }), prisma.employee.findFirst({ where: { id: employeeId }, include: { customerBranch: { include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, }, }, }), ]); if (body.provinceId && !province) throw relationError("Province"); if (body.districtId && !district) throw relationError("District"); if (body.subDistrictId && !subDistrict) throw relationError("SubDistrict"); if (body.customerBranchId && !customerBranch) throw relationError("Customer"); if (!employee) { throw new HttpError(HttpStatus.NOT_FOUND, "Employee cannot be found.", "employeeNotFound"); } await permissionCheck(req.user, employee.customerBranch.customer.registeredBranch); if (body.customerBranchId && customerBranch) { await permissionCheck(req.user, customerBranch.customer.registeredBranch); } const { provinceId, districtId, subDistrictId, customerBranchId, employeeWork, employeeCheckup, employeeOtherInfo, ...rest } = body; const listProvinceId = employeeCheckup?.reduce((acc, cur) => { if (cur.provinceId && !acc.includes(cur.provinceId)) return acc.concat(cur.provinceId); if (!cur.provinceId) cur.provinceId = null; return acc; }, []); if (listProvinceId) { const [listProvince] = await prisma.$transaction([ prisma.province.findMany({ where: { id: { in: listProvinceId } } }), ]); if (listProvince.length !== listProvinceId.length) { throw new HttpError( HttpStatus.BAD_REQUEST, "Some province cannot be found.", "someProvinceNotFound", ); } } const record = await prisma.$transaction(async (tx) => { let code: string | undefined; if ( customerBranchId !== undefined && customerBranch && customerBranch.id !== employee.customerBranchId ) { const last = await tx.runningNo.upsert({ where: { key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, }, create: { key: `EMPLOYEE_${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, value: 1, }, update: { value: { increment: 1 } }, }); code = `${customerBranch.code}-${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${last.value}`.padStart(7, "0")}`; } return await prisma.employee.update({ where: { id: employeeId }, include: { province: true, district: true, subDistrict: true, employeeOtherInfo: true, employeeCheckup: { include: { province: true, }, }, employeeWork: true, createdBy: true, updatedBy: true, }, data: { ...rest, statusOrder: +(rest.status === "INACTIVE"), code, customerBranch: connectOrNot(customerBranchId), employeeWork: employeeWork ? { deleteMany: { id: { notIn: employeeWork.map((v) => v.id).filter((v): v is string => !!v) || [], }, }, upsert: employeeWork.map((v) => ({ where: { id: v.id || "" }, create: { ...v, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, id: undefined, }, update: { ...v, updatedByUserId: req.user.sub, }, })), } : undefined, employeeCheckup: employeeCheckup ? { deleteMany: { id: { notIn: employeeCheckup.map((v) => v.id).filter((v): v is string => !!v) || [], }, }, upsert: employeeCheckup.map((v) => ({ where: { id: v.id || "" }, create: { ...v, provinceId: !v.provinceId ? undefined : v.provinceId, createdByUserId: req.user.sub, updatedByUserId: req.user.sub, id: undefined, }, update: { ...v, updatedByUserId: req.user.sub, }, })), } : undefined, employeeOtherInfo: employeeOtherInfo ? { update: employeeOtherInfo } : undefined, province: connectOrDisconnect(provinceId), district: connectOrDisconnect(districtId), subDistrict: connectOrDisconnect(subDistrictId), createdBy: { connect: { id: req.user.sub } }, updatedBy: { connect: { id: req.user.sub } }, }, }); }); const historyEntries: { field: string; valueBefore: string; valueAfter: string }[] = []; for (const k of Object.keys(body)) { const field = k as keyof typeof body; if (field === "employeeCheckup") continue; if (field === "employeeOtherInfo") continue; if (field === "employeeWork") continue; let valueBefore = employee[field]; let valueAfter = body[field]; if (valueBefore === undefined && valueAfter === undefined) continue; if (valueBefore instanceof Date) valueBefore = valueBefore.toISOString(); if (valueBefore === null || valueBefore === undefined) valueBefore = ""; if (valueAfter instanceof Date) valueAfter = valueAfter.toISOString(); if (valueAfter === null || valueAfter === undefined) valueAfter = ""; if (valueBefore !== valueAfter) historyEntries.push({ field, valueBefore, valueAfter }); } await prisma.employeeHistory.createMany({ data: historyEntries.map((v) => ({ ...v, updatedByUserId: req.user.sub, masterId: employee.id, })), }); return record; } @Delete("{employeeId}") @Security("keycloak", MANAGE_ROLES) async delete(@Request() req: RequestWithUser, @Path() employeeId: string) { const record = await prisma.employee.findFirst({ where: { id: employeeId }, include: { customerBranch: { include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(req.user), }, }, }, }, }, }, }); if (!record) throw notFoundError("Employee"); await permissionCheck(req.user, record.customerBranch.customer.registeredBranch); if (record.status !== Status.CREATED) { throw new HttpError(HttpStatus.FORBIDDEN, "Employee is in used.", "employeeInUsed"); } return await prisma.employee.delete({ include: { createdBy: true, updatedBy: true, }, where: { id: employeeId }, }); } @Get("{employeeId}/edit-history") async editHistory(@Path() employeeId: string) { return await prisma.employeeHistory.findMany({ include: { updatedBy: true, }, where: { masterId: employeeId }, }); } } @Route("api/v1/employee/{employeeId}") @Tags("Employee") export class EmployeeFileController extends Controller { private async checkPermission(user: RequestWithUser["user"], id: string) { const data = await prisma.employee.findFirst({ where: { id }, include: { customerBranch: { include: { customer: { include: { registeredBranch: { include: branchRelationPermInclude(user), }, }, }, }, }, }, }); if (!data) throw notFoundError("Employee"); await permissionCheck(user, data.customerBranch.customer.registeredBranch); } @Get("image") @Security("keycloak") async listImage(@Request() req: RequestWithUser, @Path() employeeId: string) { await this.checkPermission(req.user, employeeId); return await listFile(fileLocation.employee.img(employeeId)); } @Get("image/{name}") async getImage( @Request() req: RequestWithUser, @Path() employeeId: string, @Path() name: string, ) { return req.res?.redirect(await getFile(fileLocation.employee.img(employeeId, name))); } @Put("image/{name}") @Security("keycloak") async putImage( @Request() req: RequestWithUser, @Path() employeeId: 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, employeeId); return req.res?.redirect(await setFile(fileLocation.employee.img(employeeId, name))); } @Delete("image/{name}") @Security("keycloak") async delImage( @Request() req: RequestWithUser, @Path() employeeId: string, @Path() name: string, ) { await this.checkPermission(req.user, employeeId); return await deleteFile(fileLocation.employee.img(employeeId, name)); } @Get("attachment") @Security("keycloak") async listAttachment(@Request() req: RequestWithUser, @Path() employeeId: string) { await this.checkPermission(req.user, employeeId); return await listFile(fileLocation.employee.attachment(employeeId)); } @Get("attachment/{name}") @Security("keycloak") async getAttachment(@Path() employeeId: string, @Path() name: string) { return await getFile(fileLocation.employee.attachment(employeeId, name)); } @Put("attachment/{name}") @Security("keycloak") async putAttachment( @Request() req: RequestWithUser, @Path() employeeId: string, @Path() name: string, ) { await this.checkPermission(req.user, employeeId); return req.res?.redirect(await setFile(fileLocation.employee.attachment(employeeId, name))); } @Delete("attachment/{name}") @Security("keycloak") async delAttachment( @Request() req: RequestWithUser, @Path() employeeId: string, @Path() name: string, ) { await this.checkPermission(req.user, employeeId); return await deleteFile(fileLocation.employee.attachment(employeeId, name)); } }