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 minio, { presignedGetObjectIfExist } from "../services/minio"; if (!process.env.MINIO_BUCKET) { throw Error("Require MinIO bucket."); } const MINIO_BUCKET = process.env.MINIO_BUCKET; const MANAGE_ROLES = [ "system", "head_of_admin", "admin", "branch_admin", "branch_manager", "head_of_sale", "sale", ]; function imageLocation(id: string) { return `employee/${id}/profile-image`; } type EmployeeCreate = { customerBranchId: string; status?: Status; nrcNo?: string; dateOfBirth: Date; gender: string; nationality: string; firstName: string; firstNameEN: string; lastName: string; lastNameEN: string; addressEN: string; address: string; zipCode: string; 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; 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; firstName?: string; firstNameEN?: string; lastName?: string; lastNameEN?: string; addressEN?: string; address?: string; zipCode?: string; 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; 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( @Query() customerBranchId?: string, @Query() status?: Status, @Query() query: string = "", ) { const filterStatus = (val?: Status) => { if (!val) return {}; return val !== Status.CREATED && val !== Status.ACTIVE ? { status: val } : { OR: [{ status: Status.CREATED }, { status: Status.ACTIVE }] }; }; return await prisma.employee .groupBy({ _count: true, by: ["gender"], where: { OR: [ { firstName: { contains: query }, customerBranchId, ...filterStatus(status) }, { firstNameEN: { contains: query }, customerBranchId, ...filterStatus(status) }, { lastName: { contains: query }, customerBranchId, ...filterStatus(status) }, { lastNameEN: { contains: query }, customerBranchId, ...filterStatus(status) }, ], }, }) .then((res) => res.reduce>((a, c) => { a[c.gender] = c._count; return a; }, {}), ); } @Get() @Security("keycloak") async list( @Query() zipCode?: string, @Query() gender?: string, @Query() status?: Status, @Query() query: string = "", @Query() page: number = 1, @Query() pageSize: number = 30, ) { const filterStatus = (val?: Status) => { if (!val) return {}; return val !== Status.CREATED && val !== Status.ACTIVE ? { status: val } : { OR: [{ status: Status.CREATED }, { status: Status.ACTIVE }] }; }; const where = { OR: [ { firstName: { contains: query }, zipCode, gender, ...filterStatus(status) }, { firstNameEN: { contains: query }, zipCode, gender, ...filterStatus(status) }, { lastName: { contains: query }, zipCode, gender, ...filterStatus(status) }, { lastNameEN: { contains: query }, zipCode, gender, ...filterStatus(status) }, ], } 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: await Promise.all( result.map(async (v) => ({ ...v, profileImageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(v.id), 12 * 60 * 60, ), })), ), 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 new HttpError(HttpStatus.NOT_FOUND, "Employee cannot be found.", "employeeNotFound"); } return Object.assign(record, { profileImageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(employeeId), 12 * 60 * 60, ), }); } @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: true }, }), ]); if (body.provinceId !== province?.id) throw new HttpError( HttpStatus.BAD_REQUEST, "Province cannot be found.", "relationProvinceNotFound", ); if (body.districtId !== district?.id) throw new HttpError( HttpStatus.BAD_REQUEST, "District cannot be found.", "relationDistrictNotFound", ); if (body.subDistrictId !== subDistrict?.id) throw new HttpError( HttpStatus.BAD_REQUEST, "Sub-district cannot be found.", "relationSubDistrictNotFound", ); if (!customerBranch) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer Branch cannot be found.", "relationCustomerBranchNotFound", ); 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.customer.code.slice(0, -6)}${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`, }, create: { key: `EMPLOYEE_${customerBranch.customer.code.slice(0, -6)}${`${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.customer.code.slice(0, -6)}${`${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: { connect: provinceId ? { id: provinceId } : undefined }, district: { connect: districtId ? { id: districtId } : undefined }, subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined }, 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 Object.assign(record, { profileImageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), profileImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @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 || undefined }, include: { customer: true }, }), prisma.employee.findFirst({ where: { id: employeeId } }), ]); 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 (body.customerBranchId && !customerBranch) throw new HttpError( HttpStatus.BAD_REQUEST, "Customer cannot be found.", "relationCustomerNotFound", ); if (!employee) { throw new HttpError(HttpStatus.NOT_FOUND, "Employee cannot be found.", "employeeNotFound"); } 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.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}`, }, create: { key: `EMPLOYEE_${customerBranch.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}`, value: 1, }, update: { value: { increment: 1 } }, }); code = `${customerBranch.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}${last.value.toString().padStart(4, "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: { connect: customerBranchId ? { id: customerBranchId } : undefined }, 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: { 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, }, 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 Object.assign(record, { profileImageUrl: await presignedGetObjectIfExist( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), profileImageUploadUrl: await minio.presignedPutObject( MINIO_BUCKET, imageLocation(record.id), 12 * 60 * 60, ), }); } @Delete("{employeeId}") @Security("keycloak", MANAGE_ROLES) async delete(@Path() employeeId: string) { const record = await prisma.employee.findFirst({ where: { id: employeeId } }); if (!record) { throw new HttpError(HttpStatus.NOT_FOUND, "Employee cannot be found.", "employeeNotFound"); } 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 }, }); } }