Merge branch 'dev'

This commit is contained in:
Methapon Metanipat 2024-08-16 11:28:38 +07:00
commit a5653962fc
44 changed files with 4403 additions and 738 deletions

View file

@ -20,28 +20,32 @@
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.12.2",
"@types/node": "^20.14.9",
"@types/swagger-ui-express": "^4.1.6",
"nodemon": "^3.1.3",
"prettier": "^3.2.5",
"prisma": "^5.16.0",
"nodemon": "^3.1.4",
"prettier": "^3.3.2",
"prisma": "^5.16.1",
"prisma-kysely": "^1.8.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.3"
"typescript": "^5.5.2"
},
"dependencies": {
"@elastic/elasticsearch": "^8.13.0",
"@prisma/client": "^5.16.0",
"@tsoa/runtime": "^6.2.0",
"@elastic/elasticsearch": "^8.14.0",
"@prisma/client": "^5.16.1",
"@tsoa/runtime": "^6.3.0",
"@types/morgan": "^1.9.9",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"fast-jwt": "^4.0.0",
"fast-jwt": "^4.0.1",
"kysely": "^0.27.3",
"minio": "^7.1.3",
"minio": "^8.0.1",
"morgan": "^1.10.0",
"prisma-extension-kysely": "^2.1.0",
"promise.any": "^2.0.6",
"swagger-ui-express": "^5.0.0",
"tsoa": "^6.2.0"
"swagger-ui-express": "^5.0.1",
"tsoa": "^6.3.1",
"winston": "^3.13.1",
"winston-elasticsearch": "^0.19.0"
}
}

1122
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,238 @@
/*
Warnings:
- You are about to drop the column `createdBy` on the `Branch` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `Branch` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `BranchContact` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `BranchContact` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `BranchUser` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `BranchUser` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `Customer` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `Customer` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `CustomerBranch` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `CustomerBranch` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `Employee` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `Employee` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `EmployeeCheckup` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `EmployeeCheckup` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `EmployeeHistory` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `EmployeeOtherInfo` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `EmployeeOtherInfo` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `EmployeeWork` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `EmployeeWork` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `Product` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `Product` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `ProductGroup` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `ProductGroup` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `ProductType` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `ProductType` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `Service` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `Service` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `Work` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `Work` table. All the data in the column will be lost.
- You are about to drop the column `createdBy` on the `WorkProduct` table. All the data in the column will be lost.
- You are about to drop the column `updatedBy` on the `WorkProduct` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Branch" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "BranchContact" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "BranchUser" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "Customer" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "CustomerBranch" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "Employee" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "EmployeeCheckup" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "EmployeeHistory" DROP COLUMN "updatedBy",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "createdByUserId" TEXT,
ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "EmployeeOtherInfo" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "EmployeeWork" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "Product" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "ProductGroup" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "ProductType" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "Service" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "User" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "Work" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AlterTable
ALTER TABLE "WorkProduct" DROP COLUMN "createdBy",
DROP COLUMN "updatedBy",
ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "updatedByUserId" TEXT;
-- AddForeignKey
ALTER TABLE "Branch" ADD CONSTRAINT "Branch_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Branch" ADD CONSTRAINT "Branch_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BranchContact" ADD CONSTRAINT "BranchContact_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BranchContact" ADD CONSTRAINT "BranchContact_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BranchUser" ADD CONSTRAINT "BranchUser_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BranchUser" ADD CONSTRAINT "BranchUser_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerBranch" ADD CONSTRAINT "CustomerBranch_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerBranch" ADD CONSTRAINT "CustomerBranch_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeHistory" ADD CONSTRAINT "EmployeeHistory_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeCheckup" ADD CONSTRAINT "EmployeeCheckup_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeCheckup" ADD CONSTRAINT "EmployeeCheckup_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeWork" ADD CONSTRAINT "EmployeeWork_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeWork" ADD CONSTRAINT "EmployeeWork_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeOtherInfo" ADD CONSTRAINT "EmployeeOtherInfo_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeOtherInfo" ADD CONSTRAINT "EmployeeOtherInfo_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Work" ADD CONSTRAINT "Work_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Work" ADD CONSTRAINT "Work_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkProduct" ADD CONSTRAINT "WorkProduct_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkProduct" ADD CONSTRAINT "WorkProduct_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductGroup" ADD CONSTRAINT "ProductGroup_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductGroup" ADD CONSTRAINT "ProductGroup_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductType" ADD CONSTRAINT "ProductType_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductType" ADD CONSTRAINT "ProductType_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,15 @@
/*
Warnings:
- You are about to drop the column `createdAt` on the `EmployeeHistory` table. All the data in the column will be lost.
- You are about to drop the column `createdByUserId` on the `EmployeeHistory` table. All the data in the column will be lost.
- You are about to drop the column `timestamp` on the `EmployeeHistory` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "EmployeeHistory" DROP CONSTRAINT "EmployeeHistory_createdByUserId_fkey";
-- AlterTable
ALTER TABLE "EmployeeHistory" DROP COLUMN "createdAt",
DROP COLUMN "createdByUserId",
DROP COLUMN "timestamp";

View file

@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "Customer" ADD COLUMN "registeredBranchId" TEXT;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "registeredBranchId" TEXT;
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "productTypeId" TEXT,
ADD COLUMN "registeredBranchId" TEXT;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_registeredBranchId_fkey" FOREIGN KEY ("registeredBranchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_registeredBranchId_fkey" FOREIGN KEY ("registeredBranchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_productTypeId_fkey" FOREIGN KEY ("productTypeId") REFERENCES "ProductType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_registeredBranchId_fkey" FOREIGN KEY ("registeredBranchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Employee" ALTER COLUMN "nrcNo" DROP NOT NULL;

View file

@ -0,0 +1,119 @@
-- CreateEnum
CREATE TYPE "PayCondition" AS ENUM ('Full', 'Split', 'BillFull', 'BillSplit');
-- CreateTable
CREATE TABLE "Quotation" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"customerBranchId" TEXT NOT NULL,
"status" "Status" NOT NULL DEFAULT 'CREATED',
"statusOrder" INTEGER NOT NULL DEFAULT 0,
"code" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"payCondition" "PayCondition" NOT NULL,
"paySplitCount" INTEGER,
"payBillDate" TIMESTAMP(3),
"workerCount" INTEGER NOT NULL,
"urgent" BOOLEAN NOT NULL DEFAULT false,
"totalPrice" DOUBLE PRECISION NOT NULL,
"totalDiscount" DOUBLE PRECISION NOT NULL,
"vat" DOUBLE PRECISION NOT NULL,
"vatExcluded" DOUBLE PRECISION NOT NULL,
"finalPrice" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdByUserId" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedByUserId" TEXT,
CONSTRAINT "Quotation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QuotationPaySplit" (
"id" TEXT NOT NULL,
"no" INTEGER NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quotationId" TEXT,
CONSTRAINT "QuotationPaySplit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QuotationWorker" (
"id" TEXT NOT NULL,
"no" INTEGER NOT NULL,
"code" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"quotationId" TEXT NOT NULL,
CONSTRAINT "QuotationWorker_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QuotationService" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"detail" TEXT NOT NULL,
"attributes" JSONB,
"quotationId" TEXT NOT NULL,
CONSTRAINT "QuotationService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QuotationServiceWork" (
"id" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"attributes" JSONB,
"serviceId" TEXT NOT NULL,
CONSTRAINT "QuotationServiceWork_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QuotationServiceWorkProduct" (
"order" INTEGER NOT NULL,
"workId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"vat" DOUBLE PRECISION NOT NULL,
"amount" INTEGER NOT NULL,
"discount" DOUBLE PRECISION NOT NULL,
"pricePerUnit" DOUBLE PRECISION NOT NULL,
CONSTRAINT "QuotationServiceWorkProduct_pkey" PRIMARY KEY ("workId","productId")
);
-- AddForeignKey
ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_customerBranchId_fkey" FOREIGN KEY ("customerBranchId") REFERENCES "CustomerBranch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Quotation" ADD CONSTRAINT "Quotation_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationPaySplit" ADD CONSTRAINT "QuotationPaySplit_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationWorker" ADD CONSTRAINT "QuotationWorker_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationWorker" ADD CONSTRAINT "QuotationWorker_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationService" ADD CONSTRAINT "QuotationService_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationServiceWork" ADD CONSTRAINT "QuotationServiceWork_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "QuotationService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationServiceWorkProduct" ADD CONSTRAINT "QuotationServiceWorkProduct_workId_fkey" FOREIGN KEY ("workId") REFERENCES "QuotationServiceWork"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationServiceWorkProduct" ADD CONSTRAINT "QuotationServiceWorkProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `refServiceId` to the `QuotationService` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "QuotationService" ADD COLUMN "refServiceId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "QuotationService" ADD CONSTRAINT "QuotationService_refServiceId_fkey" FOREIGN KEY ("refServiceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,17 @@
-- DropForeignKey
ALTER TABLE "QuotationService" DROP CONSTRAINT "QuotationService_quotationId_fkey";
-- DropForeignKey
ALTER TABLE "QuotationServiceWork" DROP CONSTRAINT "QuotationServiceWork_serviceId_fkey";
-- DropForeignKey
ALTER TABLE "QuotationServiceWorkProduct" DROP CONSTRAINT "QuotationServiceWorkProduct_workId_fkey";
-- AddForeignKey
ALTER TABLE "QuotationService" ADD CONSTRAINT "QuotationService_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationServiceWork" ADD CONSTRAINT "QuotationServiceWork_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "QuotationService"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuotationServiceWorkProduct" ADD CONSTRAINT "QuotationServiceWorkProduct_workId_fkey" FOREIGN KEY ("workId") REFERENCES "QuotationServiceWork"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "BranchBank" (
"id" TEXT NOT NULL,
"bankName" TEXT NOT NULL,
"accountName" TEXT NOT NULL,
"accountNumber" TEXT NOT NULL,
"branchId" TEXT,
CONSTRAINT "BranchBank_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "BranchBank" ADD CONSTRAINT "BranchBank_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,20 @@
/*
Warnings:
- Added the required column `accountType` to the `BranchBank` table without a default value. This is not possible if the table is not empty.
- Added the required column `bankBranch` to the `BranchBank` table without a default value. This is not possible if the table is not empty.
- Added the required column `currentlyUse` to the `BranchBank` table without a default value. This is not possible if the table is not empty.
- Made the column `branchId` on table `BranchBank` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "BranchBank" DROP CONSTRAINT "BranchBank_branchId_fkey";
-- AlterTable
ALTER TABLE "BranchBank" ADD COLUMN "accountType" TEXT NOT NULL,
ADD COLUMN "bankBranch" TEXT NOT NULL,
ADD COLUMN "currentlyUse" BOOLEAN NOT NULL,
ALTER COLUMN "branchId" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "BranchBank" ADD CONSTRAINT "BranchBank_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[employeeId]` on the table `EmployeeOtherInfo` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "EmployeeOtherInfo_employeeId_key" ON "EmployeeOtherInfo"("employeeId");

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "middleName" TEXT,
ADD COLUMN "middleNameEN" TEXT,
ADD COLUMN "namePrefix" TEXT;

View file

@ -22,7 +22,7 @@ model Menu {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
parent Menu? @relation(name: "MenuRelation", fields: [parentId], references: [id])
@ -45,7 +45,7 @@ model RoleMenuPermission {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
}
@ -62,7 +62,7 @@ model UserMenuPermission {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
}
@ -77,7 +77,7 @@ model MenuComponent {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
roleMenuComponentPermission RoleMenuComponentPermission[]
userMennuComponentPermission UserMenuComponentPermission[]
@ -94,7 +94,7 @@ model RoleMenuComponentPermission {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
}
@ -116,7 +116,7 @@ model UserMenuComponentPermission {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
}
@ -127,7 +127,7 @@ model Province {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
district District[]
@ -148,7 +148,7 @@ model District {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
subDistrict SubDistrict[]
@ -169,7 +169,7 @@ model SubDistrict {
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedBy String?
updatedAt DateTime @updatedAt
branch Branch[]
@ -217,17 +217,38 @@ model Branch {
headOffice Branch? @relation(name: "HeadOfficeRelation", fields: [headOfficeId], references: [id])
headOfficeId String?
bank BranchBank[]
status Status @default(CREATED)
statusOrder Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "BranchCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "BranchUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
branch Branch[] @relation(name: "HeadOfficeRelation")
contact BranchContact[]
user BranchUser[]
productRegistration Product[]
serviceRegistration Service[]
customerRegistration Customer[]
}
model BranchBank {
id String @id @default(uuid())
bankName String
bankBranch String
accountName String
accountNumber String
accountType String
currentlyUse Boolean
branch Branch @relation(fields: [branchId], references: [id], onDelete: Cascade)
branchId String
}
model BranchContact {
@ -237,10 +258,12 @@ model BranchContact {
branch Branch @relation(fields: [branchId], references: [id], onDelete: Cascade)
branchId String
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "BranchContactCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "BranchContactUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
model BranchUser {
@ -252,10 +275,12 @@ model BranchUser {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "BranchUserCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "BranchUserUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
enum UserType {
@ -268,13 +293,16 @@ enum UserType {
model User {
id String @id @default(uuid())
code String?
firstName String
firstNameEN String
lastName String
lastNameEN String
username String
gender String
code String?
namePrefix String?
firstName String
firstNameEN String
middleName String?
middleNameEN String?
lastName String
lastNameEN String
username String
gender String
address String
addressEN String
@ -321,15 +349,52 @@ model User {
status Status @default(CREATED)
statusOrder Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "UserCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "UserUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
branch BranchUser[]
userMenuPermission UserMenuPermission[]
userMenuComponentPermission UserMenuComponentPermission[]
employeeHistory EmployeeHistory[]
userCreated User[] @relation("UserCreatedByUser")
userUpdated User[] @relation("UserUpdatedByUser")
branchCreated Branch[] @relation("BranchCreatedByUser")
branchUpdated Branch[] @relation("BranchUpdatedByUser")
branchContactCreated BranchContact[] @relation("BranchContactCreatedByUser")
branchContactUpdated BranchContact[] @relation("BranchContactUpdatedByUser")
branchUserCreated BranchUser[] @relation("BranchUserCreatedByUser")
branchUserUpdated BranchUser[] @relation("BranchUserUpdatedByUser")
customerCreated Customer[] @relation("CustomerCreatedByUser")
customerUpdated Customer[] @relation("CustomerUpdatedByUser")
customerBranchCreated CustomerBranch[] @relation("CustomerBranchCreatedByUser")
customerBranchUpdated CustomerBranch[] @relation("CustomerBranchUpdatedByUser")
emplyeeCreated Employee[] @relation("EmployeeCreatedByUser")
employeUpdated Employee[] @relation("EmployeeUpdatedByUser")
employeeHistoryUpdated EmployeeHistory[] @relation("EmployeeHistoryUpdatedByUser")
employeeCheckupCreated EmployeeCheckup[] @relation("EmployeeCheckupCreatedByUser")
employeeCheckupUpdated EmployeeCheckup[] @relation("EmployeeCheckupUpdatedByUser")
employeeWorkCreated EmployeeWork[] @relation("EmployeeWorkCreatedByUser")
employeeWorkUpdated EmployeeWork[] @relation("EmployeeWorkUpdatedByUser")
employeeOtherInfoCreated EmployeeOtherInfo[] @relation("EmployeeOtherInfoCreatedByUser")
employeeOtherInfoUpdated EmployeeOtherInfo[] @relation("EmployeeOtherInfoUpdatedByUser")
serviceCreated Service[] @relation("ServiceCreatedByUser")
serviceUpdated Service[] @relation("ServiceUpdatedByUser")
workCreated Work[] @relation("WorkCreatedByUser")
workUpdated Work[] @relation("WorkUpdatedByUser")
workProductCreated WorkProduct[] @relation("WorkProductCreatedByUser")
workProductUpdated WorkProduct[] @relation("WorkProductUpdatedByUser")
productGroupCreated ProductGroup[] @relation("ProductGroupCreatedByUser")
productGroupUpdated ProductGroup[] @relation("ProductGroupUpdatedByUser")
productTypeCreated ProductType[] @relation("ProductTypeCreatedByUser")
productTypeUpdated ProductType[] @relation("ProductTypeUpdatedByUser")
productCreated Product[] @relation("ProductCreatedByUser")
productUpdated Product[] @relation("ProductUpdatedByUser")
quotationCreated Quotation[] @relation("QuotationCreatedByUser")
quotationUpdated Quotation[] @relation("QuotationUpdatedByUser")
}
enum CustomerType {
@ -350,12 +415,18 @@ model Customer {
status Status @default(CREATED)
statusOrder Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
registeredBranchId String?
registeredBranch Branch? @relation(fields: [registeredBranchId], references: [id])
branch CustomerBranch[]
createdAt DateTime @default(now())
createdBy User? @relation(name: "CustomerCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "CustomerUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
branch CustomerBranch[]
quotation Quotation[]
}
model CustomerBranch {
@ -405,19 +476,22 @@ model CustomerBranch {
status Status @default(CREATED)
statusOrder Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "CustomerBranchCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "CustomerBranchUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
employee Employee[]
employee Employee[]
quotation Quotation[]
}
model Employee {
id String @id @default(uuid())
code String
nrcNo String
nrcNo String?
firstName String
firstNameEN String
lastName String
@ -465,16 +539,19 @@ model Employee {
status Status @default(CREATED)
statusOrder Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "EmployeeCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "EmployeeUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
employeeCheckup EmployeeCheckup[]
employeeWork EmployeeWork[]
employeeOtherInfo EmployeeOtherInfo[]
employeeOtherInfo EmployeeOtherInfo?
editHistory EmployeeHistory[]
editHistory EmployeeHistory[]
quotationWorker QuotationWorker[]
}
model EmployeeHistory {
@ -483,12 +560,9 @@ model EmployeeHistory {
valueBefore Json
valueAfter Json
timestamp DateTime @default(now())
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "EmployeeHistoryUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
updatedByUser User? @relation(fields: [updatedByUserId], references: [id])
updatedBy String?
updatedAt DateTime @default(now())
masterId String
master Employee @relation(fields: [masterId], references: [id], onDelete: Cascade)
@ -513,10 +587,12 @@ model EmployeeCheckup {
coverageStartDate DateTime?
coverageExpireDate DateTime?
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "EmployeeCheckupCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "EmployeeCheckupUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
model EmployeeWork {
@ -535,17 +611,19 @@ model EmployeeWork {
workEndDate DateTime?
remark String?
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "EmployeeWorkCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "EmployeeWorkUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
model EmployeeOtherInfo {
id String @id @default(uuid())
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
employeeId String
employeeId String @unique
citizenId String?
fatherBirthPlace String?
@ -560,65 +638,12 @@ model EmployeeOtherInfo {
motherFirstNameEN String?
motherLastNameEN String?
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
}
model Service {
id String @id @default(uuid())
code String
name String
detail String
attributes Json?
status Status @default(CREATED)
statusOrder Int @default(0)
work Work[]
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
}
model Work {
id String @id @default(uuid())
order Int
name String
attributes Json?
status Status @default(CREATED)
statusOrder Int @default(0)
service Service? @relation(fields: [serviceId], references: [id], onDelete: Cascade)
serviceId String?
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
productOnWork WorkProduct[]
}
model WorkProduct {
order Int
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
workId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
@@id([workId, productId])
createdAt DateTime @default(now())
createdBy User? @relation(name: "EmployeeOtherInfoCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "EmployeeOtherInfoUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
model ProductGroup {
@ -632,10 +657,12 @@ model ProductGroup {
status Status @default(CREATED)
statusOrder Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "ProductGroupCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "ProductGroupUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
type ProductType[]
}
@ -651,15 +678,18 @@ model ProductType {
status Status @default(CREATED)
statusOrder Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
createdBy User? @relation(name: "ProductTypeCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "ProductTypeUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
productGroup ProductGroup @relation(fields: [productGroupId], references: [id], onDelete: Cascade)
productGroupId String
product Product[]
service Service[]
}
model Product {
@ -681,10 +711,199 @@ model Product {
productType ProductType? @relation(fields: [productTypeId], references: [id], onDelete: SetNull)
productTypeId String?
workProduct WorkProduct[]
registeredBranchId String?
registeredBranch Branch? @relation(fields: [registeredBranchId], references: [id])
createdBy String?
createdAt DateTime @default(now())
updatedBy String?
updatedAt DateTime @updatedAt
workProduct WorkProduct[]
quotationServiceWorkProduct QuotationServiceWorkProduct[]
createdAt DateTime @default(now())
createdBy User? @relation(name: "ProductCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "ProductUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
model Service {
id String @id @default(uuid())
code String
name String
detail String
attributes Json?
status Status @default(CREATED)
statusOrder Int @default(0)
work Work[]
quotationService QuotationService[]
productType ProductType? @relation(fields: [productTypeId], references: [id], onDelete: SetNull)
productTypeId String?
registeredBranchId String?
registeredBranch Branch? @relation(fields: [registeredBranchId], references: [id])
createdAt DateTime @default(now())
createdBy User? @relation(name: "ServiceCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "ServiceUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
model Work {
id String @id @default(uuid())
order Int
name String
attributes Json?
status Status @default(CREATED)
statusOrder Int @default(0)
service Service? @relation(fields: [serviceId], references: [id], onDelete: Cascade)
serviceId String?
createdAt DateTime @default(now())
createdBy User? @relation(name: "WorkCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "WorkUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
productOnWork WorkProduct[]
}
model WorkProduct {
order Int
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
workId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
createdAt DateTime @default(now())
createdBy User? @relation(name: "WorkProductCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "WorkProductUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
@@id([workId, productId])
}
enum PayCondition {
Full
Split
BillFull
BillSplit
}
model Quotation {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
customerBranchId String
customerBranch CustomerBranch @relation(fields: [customerBranchId], references: [id])
status Status @default(CREATED)
statusOrder Int @default(0)
code String
date DateTime @default(now())
payCondition PayCondition
paySplitCount Int?
paySplit QuotationPaySplit[]
payBillDate DateTime?
workerCount Int
worker QuotationWorker[]
service QuotationService[]
urgent Boolean @default(false)
totalPrice Float
totalDiscount Float
vat Float
vatExcluded Float
finalPrice Float
createdAt DateTime @default(now())
createdBy User? @relation(name: "QuotationCreatedByUser", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdByUserId String?
updatedAt DateTime @updatedAt
updatedBy User? @relation(name: "QuotationUpdatedByUser", fields: [updatedByUserId], references: [id], onDelete: SetNull)
updatedByUserId String?
}
model QuotationPaySplit {
id String @id @default(uuid())
no Int
date DateTime
quotation Quotation? @relation(fields: [quotationId], references: [id])
quotationId String?
}
model QuotationWorker {
id String @id @default(uuid())
no Int
code String
employee Employee @relation(fields: [employeeId], references: [id])
employeeId String
quotation Quotation @relation(fields: [quotationId], references: [id])
quotationId String
}
model QuotationService {
id String @id @default(uuid())
code String
name String
detail String
attributes Json?
work QuotationServiceWork[]
refServiceId String
refService Service @relation(fields: [refServiceId], references: [id])
quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade)
quotationId String
}
model QuotationServiceWork {
id String @id @default(uuid())
order Int
name String
attributes Json?
service QuotationService @relation(fields: [serviceId], references: [id], onDelete: Cascade)
serviceId String
productOnWork QuotationServiceWorkProduct[]
}
model QuotationServiceWorkProduct {
order Int
work QuotationServiceWork @relation(fields: [workId], references: [id], onDelete: Cascade)
workId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
vat Float
amount Int
discount Float
pricePerUnit Float
@@id([workId, productId])
}

View file

@ -4,8 +4,10 @@ import express, { json, urlencoded } from "express";
import swaggerUi from "swagger-ui-express";
import swaggerDocument from "./swagger.json";
import error from "./middlewares/error";
import morgan from "./middlewares/morgan";
import { RegisterRoutes } from "./routes";
import logMiddleware from "./middlewares/log";
import { addUserRoles, createUser, getRoleByName, listUser } from "./services/keycloak";
import prisma from "./db";
const APP_HOST = process.env.APP_HOST || "0.0.0.0";
const APP_PORT = +(process.env.APP_PORT || 3000);
@ -13,11 +15,64 @@ const APP_PORT = +(process.env.APP_PORT || 3000);
(async () => {
const app = express();
let users = await (async () => {
let list = await listUser();
while (!list) {
list = await listUser();
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return list;
})();
if (users.length === 0) {
const role = await getRoleByName("system");
const userId = await createUser("admin", "1234", {
firstName: "Admin",
lastName: "System",
email: "admin@jws.local",
requiredActions: ["UPDATE_PASSWORD"],
enabled: true,
});
if (!userId || typeof userId !== "string") {
throw new Error("Error create user with keycloak service.");
}
if (role) await addUserRoles(userId, [role]);
await prisma.user.create({
include: { province: true, district: true, subDistrict: true },
data: {
id: userId,
email: "admin@jws.local",
gender: "",
address: "",
addressEN: "",
zipCode: "",
userType: "USER",
userRole: "system",
telephoneNo: "",
firstName: "Admin",
firstNameEN: "Admin",
lastName: "System",
lastNameEN: "System",
statusOrder: 0,
username: "admin",
},
});
}
const originalSend = app.response.json;
app.response.json = function (body: unknown) {
this.app.locals.response = body;
return originalSend.call(this, body);
};
app.use(cors());
app.use(json());
app.use(urlencoded({ extended: true }));
app.use(logMiddleware);
app.use(morgan);
app.use("/", express.static("static"));
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));

8
src/config.json Normal file
View file

@ -0,0 +1,8 @@
{
"branch": {
"maxHeadOfficeBranch": 10
},
"personnel": {
"type": ["USER", "MESSENGER", "DELEGATE", "AGENCY"]
}
}

View file

@ -38,6 +38,7 @@ export class BranchContactController extends Controller {
) {
const [result, total] = await prisma.$transaction([
prisma.branchContact.findMany({
include: { createdBy: true, updatedBy: true },
orderBy: { createdAt: "asc" },
where: { branchId },
take: pageSize,
@ -79,7 +80,11 @@ export class BranchContactController extends Controller {
throw new HttpError(HttpStatus.BAD_REQUEST, "Branch cannot be found.", "branchBadReq");
}
const record = await prisma.branchContact.create({
data: { ...body, branchId, createdBy: req.user.name, updatedBy: req.user.name },
include: {
createdBy: true,
updatedBy: true,
},
data: { ...body, branchId, createdByUserId: req.user.sub, updatedByUserId: req.user.sub },
});
this.setStatus(HttpStatus.CREATED);
@ -107,7 +112,8 @@ export class BranchContactController extends Controller {
}
const record = await prisma.branchContact.update({
data: { ...body, updatedBy: req.user.name },
include: { createdBy: true, updatedBy: true },
data: { ...body, updatedByUserId: req.user.sub },
where: { id: contactId, branchId },
});

View file

@ -18,7 +18,7 @@ import prisma from "../db";
import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
import { RequestWithUser } from "../interfaces/user";
import minio from "../services/minio";
import minio, { presignedGetObjectIfExist } from "../services/minio";
if (!process.env.MINIO_BUCKET) {
throw Error("Require MinIO bucket.");
@ -28,6 +28,7 @@ const MINIO_BUCKET = process.env.MINIO_BUCKET;
type BranchCreate = {
status?: Status;
code: string;
taxNo: string;
nameEN: string;
name: string;
@ -42,6 +43,15 @@ type BranchCreate = {
longitude: string;
latitude: string;
bank?: {
bankName: string;
bankBranch: string;
accountName: string;
accountNumber: string;
accountType: string;
currentlyUse: boolean;
}[];
subDistrictId?: string | null;
districtId?: string | null;
provinceId?: string | null;
@ -68,6 +78,15 @@ type BranchUpdate = {
districtId?: string | null;
provinceId?: string | null;
headOfficeId?: string | null;
bank?: {
bankName: string;
bankBranch: string;
accountName: string;
accountNumber: string;
accountType: string;
currentlyUse: boolean;
}[];
};
function lineImageLoc(id: string) {
@ -80,9 +99,9 @@ function branchImageLoc(id: string) {
@Route("api/v1/branch")
@Tags("Branch")
@Security("keycloak")
export class BranchController extends Controller {
@Get("stats")
@Security("keycloak")
async getStats() {
const list = await prisma.branch.groupBy({
_count: true,
@ -99,14 +118,27 @@ export class BranchController extends Controller {
}
@Get("user-stats")
async getUserStat(@Query() userType?: UserType) {
@Security("keycloak")
async getUserStat(@Request() req: RequestWithUser, @Query() userType?: UserType) {
const list = await prisma.branchUser.groupBy({
_count: true,
where: { user: { userType } },
where: {
userId: !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v))
? req.user.sub
: undefined,
user: {
userType,
},
},
by: "branchId",
});
const record = await prisma.branch.findMany({
where: {
user: !["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v))
? { some: { userId: req.user.sub } }
: undefined,
},
select: {
id: true,
headOfficeId: true,
@ -136,6 +168,7 @@ export class BranchController extends Controller {
}
@Get()
@Security("keycloak")
async getBranch(
@Query() zipCode?: string,
@Query() filter?: "head" | "sub",
@ -173,6 +206,12 @@ export class BranchController extends Controller {
subDistrict: true,
},
},
bank: true,
_count: {
select: { branch: true },
},
createdBy: true,
updatedBy: true,
},
where,
take: pageSize,
@ -185,6 +224,7 @@ export class BranchController extends Controller {
}
@Get("{branchId}")
@Security("keycloak")
async getBranchById(
@Path() branchId: string,
@Query() includeSubBranch?: boolean,
@ -195,6 +235,8 @@ export class BranchController extends Controller {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
branch: includeSubBranch && {
include: {
province: true,
@ -202,6 +244,7 @@ export class BranchController extends Controller {
subDistrict: true,
},
},
bank: true,
contact: includeContact,
},
where: { id: branchId },
@ -218,6 +261,7 @@ export class BranchController extends Controller {
}
@Post()
@Security("keycloak", ["system", "head_of_admin", "admin"])
async createBranch(@Request() req: RequestWithUser, @Body() body: BranchCreate) {
const [province, district, subDistrict, head] = await prisma.$transaction([
prisma.province.findFirst({ where: { id: body.provinceId || undefined } }),
@ -250,44 +294,70 @@ export class BranchController extends Controller {
"relationHQNotFound",
);
const { provinceId, districtId, subDistrictId, headOfficeId, contact, ...rest } = body;
const { provinceId, districtId, subDistrictId, headOfficeId, bank, contact, code, ...rest } =
body;
const year = new Date().getFullYear();
if (headOfficeId && head && head.code.slice(0, -6) !== code) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Headoffice code not match with branch code",
"codeMismatch",
);
}
const record = await prisma.$transaction(
async (tx) => {
const last = await tx.runningNo.upsert({
where: {
key: !headOfficeId ? `HQ${year.toString().slice(2)}` : `BR${head?.code.slice(2, 5)}`,
key: `MAIN_BRANCH_${code}`,
},
create: {
key: !headOfficeId ? `HQ${year.toString().slice(2)}` : `BR${head?.code.slice(2, 5)}`,
key: `MAIN_BRANCH_${code}`,
value: 1,
},
update: { value: { increment: 1 } },
});
const code = !headOfficeId
? `HQ${year.toString().slice(2)}${last.value}`
: `BR${head?.code.slice(2, 5)}${last.value.toString().padStart(2, "0")}`;
if (last.value === 1) {
const exist = await tx.branch.findFirst({
where: { code: `${code?.toLocaleUpperCase()}${`${last.value - 1}`.padStart(6, "0")}` },
});
if (exist)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch with same code already exists.",
"sameBranchCodeExists",
);
}
if (last.value !== 1 && !headOfficeId) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch with same code already exists.",
"sameBranchCodeExists",
);
}
return await tx.branch.create({
include: {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
data: {
...rest,
statusOrder: +(rest.status === "INACTIVE"),
code,
code: `${code?.toLocaleUpperCase()}${`${last.value - 1}`.padStart(6, "0")}`,
bank: bank ? { createMany: { data: bank } } : undefined,
isHeadOffice: !headOfficeId,
province: { connect: provinceId ? { id: provinceId } : undefined },
district: { connect: districtId ? { id: districtId } : undefined },
subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined },
headOffice: { connect: headOfficeId ? { id: headOfficeId } : undefined },
createdBy: req.user.name,
updatedBy: req.user.name,
createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } },
},
});
},
@ -315,12 +385,12 @@ export class BranchController extends Controller {
return Object.assign(record, {
contact: await prisma.branchContact.findMany({ where: { branchId: record.id } }),
imageUrl: await minio.presignedGetObject(MINIO_BUCKET, branchImageLoc(record.id)),
imageUploadUrl: await minio.presignedPutObject(MINIO_BUCKET, branchImageLoc(record.id)),
qrCodeImageUrl: await minio.presignedGetObject(
imageUploadUrl: await minio.presignedPutObject(
MINIO_BUCKET,
lineImageLoc(record.id),
branchImageLoc(record.id),
12 * 60 * 60,
),
qrCodeImageUrl: await minio.presignedGetObject(MINIO_BUCKET, lineImageLoc(record.id)),
qrCodeImageUploadUrl: await minio.presignedPutObject(
MINIO_BUCKET,
lineImageLoc(record.id),
@ -330,6 +400,7 @@ export class BranchController extends Controller {
}
@Put("{branchId}")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"])
async editBranch(
@Request() req: RequestWithUser,
@Body() body: BranchUpdate,
@ -375,18 +446,37 @@ export class BranchController extends Controller {
);
}
const { provinceId, districtId, subDistrictId, headOfficeId, contact, ...rest } = body;
const { provinceId, districtId, subDistrictId, headOfficeId, bank, contact, ...rest } = body;
if (!(await prisma.branch.findUnique({ where: { id: branchId } }))) {
const branch = await prisma.branch.findUnique({
include: {
user: { where: { userId: req.user.sub } },
},
where: { id: branchId },
});
if (!branch) {
throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound");
}
if (
!["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) &&
!branch?.user.find((v) => v.userId === req.user.sub)
) {
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
const record = await prisma.branch.update({
include: { province: true, district: true, subDistrict: true },
data: {
...rest,
statusOrder: +(rest.status === "INACTIVE"),
isHeadOffice: headOfficeId !== undefined ? headOfficeId === null : undefined,
bank: bank ? { deleteMany: {}, createMany: { data: bank } } : undefined,
province: {
connect: provinceId ? { id: provinceId } : undefined,
disconnect: provinceId === null || undefined,
@ -403,7 +493,7 @@ export class BranchController extends Controller {
connect: headOfficeId ? { id: headOfficeId } : undefined,
disconnect: headOfficeId === null || undefined,
},
updatedBy: req.user.name,
updatedBy: { connect: { id: req.user.sub } },
},
where: { id: branchId },
});
@ -421,12 +511,12 @@ export class BranchController extends Controller {
return Object.assign(record, {
imageUrl: await minio.presignedGetObject(MINIO_BUCKET, branchImageLoc(record.id)),
imageUploadUrl: await minio.presignedPutObject(MINIO_BUCKET, branchImageLoc(record.id)),
qrCodeImageUrl: await minio.presignedGetObject(
imageUploadUrl: await minio.presignedPutObject(
MINIO_BUCKET,
lineImageLoc(record.id),
branchImageLoc(record.id),
12 * 60 * 60,
),
qrCodeImageUrl: await minio.presignedGetObject(MINIO_BUCKET, lineImageLoc(record.id)),
qrCodeImageUploadUrl: await minio.presignedPutObject(
MINIO_BUCKET,
lineImageLoc(record.id),
@ -436,16 +526,33 @@ export class BranchController extends Controller {
}
@Delete("{branchId}")
async deleteBranch(@Path() branchId: string) {
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_manager"])
async deleteBranch(@Request() req: RequestWithUser, @Path() branchId: string) {
const record = await prisma.branch.findFirst({
include: {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
user: { where: { userId: req.user.sub } },
},
where: { id: branchId },
});
if (!["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v))) {
if (
record?.createdByUserId !== req.user.sub &&
!record?.user.find((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, "Branch cannot be found.", "branchNotFound");
}
@ -454,20 +561,116 @@ export class BranchController extends Controller {
throw new HttpError(HttpStatus.FORBIDDEN, "Branch is in used.", "branchInUsed");
}
await minio.removeObject(MINIO_BUCKET, lineImageLoc(branchId), {
forceDelete: true,
});
await minio.removeObject(MINIO_BUCKET, branchImageLoc(branchId), {
forceDelete: true,
});
return await prisma.$transaction(async (tx) => {
const data = await tx.branch.delete({
include: {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
where: { id: branchId },
});
return await prisma.branch.delete({
if (record.isHeadOffice) {
await tx.runningNo.delete({
where: {
key: `MAIN_BRANCH_${record.code.slice(0, -6)}`,
},
});
}
await minio.removeObject(MINIO_BUCKET, lineImageLoc(branchId), {
forceDelete: true,
});
await minio.removeObject(MINIO_BUCKET, branchImageLoc(branchId), {
forceDelete: true,
});
return data;
});
}
@Get("{branchId}/line-image")
async getLineImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) {
const url = await presignedGetObjectIfExist(MINIO_BUCKET, lineImageLoc(branchId), 60 * 60);
if (!url) {
throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound");
}
return req.res?.redirect(url);
}
@Put("{branchId}/line-image")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"])
async setLineImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) {
const record = await prisma.branch.findUnique({
include: {
province: true,
district: true,
subDistrict: true,
user: { where: { userId: req.user.sub } },
},
where: { id: branchId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound");
}
if (
!["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) &&
!record?.user.find((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, lineImageLoc(record.id), 12 * 60 * 60),
);
}
@Get("{branchId}/branch-image")
async getBranchImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) {
const url = await presignedGetObjectIfExist(MINIO_BUCKET, branchImageLoc(branchId), 60 * 60);
if (!url) {
throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound");
}
return req.res?.redirect(url);
}
@Put("{branchId}/branch-image")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"])
async setBranchImageByBranchId(@Request() req: RequestWithUser, @Path() branchId: string) {
const record = await prisma.branch.findUnique({
include: {
user: { where: { userId: req.user.sub } },
},
where: { id: branchId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound");
}
if (
!["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) &&
!record?.user.find((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, branchImageLoc(record.id), 12 * 60 * 60),
);
}
}

View file

@ -1,4 +1,4 @@
import { Branch, Prisma, Status, User, UserType } from "@prisma/client";
import { Branch, Prisma, Status, User } from "@prisma/client";
import {
Body,
Controller,
@ -28,21 +28,28 @@ async function userBranchCodeGen(branch: Branch, user: User[]) {
const typ = usr.userType;
const mapTypeNo = {
USER: 1,
MESSENGER: 2,
DELEGATE: 3,
AGENCY: 4,
}[typ];
const last = await tx.runningNo.upsert({
where: {
key: `BR_USR_${branch.code.slice(4).padEnd(3, "0")}${typ !== "USER" ? typ.charAt(0).toLocaleUpperCase() : ""}`,
key: `BR_USR_${branch.code}_${mapTypeNo}`,
},
create: {
key: `BR_USR_${branch.code.slice(4).padEnd(3, "0")}${typ !== "USER" ? typ.charAt(0).toLocaleUpperCase() : ""}`,
key: `BR_USR_${branch.code}_${mapTypeNo}`,
value: 1,
},
update: { value: { increment: 1 } },
});
await prisma.user.update({
await tx.user.update({
where: { id: usr.id },
data: {
code: `${last.key.slice(7)}${last.value.toString().padStart(4, "0")}`,
code: mapTypeNo + `${last.value}`.padStart(6, "0"),
},
});
}
@ -51,11 +58,28 @@ async function userBranchCodeGen(branch: Branch, user: User[]) {
);
}
@Route("api/v1/branch/{branchId}/admin")
@Tags("Branch User")
export class BranchAdminUserController extends Controller {
@Get()
@Security("keycloak")
async getBranchAdmin(@Path() branchId: string) {
return await prisma.user.findFirst({
where: {
branch: {
some: { branchId },
},
userRole: "branch_admin",
},
});
}
}
@Route("api/v1/branch/{branchId}/user")
@Tags("Branch User")
@Security("keycloak")
export class BranchUserController extends Controller {
@Get()
@Security("keycloak")
async getBranchUser(
@Path() branchId: string,
@Query() zipCode?: string,
@ -97,6 +121,7 @@ export class BranchUserController extends Controller {
}
@Post()
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"])
async createBranchUser(
@Request() req: RequestWithUser,
@Path() branchId: string,
@ -104,6 +129,11 @@ export class BranchUserController extends Controller {
) {
const [branch, user] = await prisma.$transaction([
prisma.branch.findUnique({
include: {
user: {
where: { userId: req.user.sub },
},
},
where: { id: branchId },
}),
prisma.user.findMany({
@ -112,14 +142,22 @@ export class BranchUserController extends Controller {
}),
]);
if (!branch) {
if (
!["system", "head_of_admin", "admin"].some((v) => req.user.roles?.includes(v)) &&
branch?.createdByUserId !== req.user.sub &&
!branch?.user.find((v) => v.userId === req.user.sub)
) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch cannot be found.",
"branchBadReq",
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
if (!branch) {
throw new HttpError(HttpStatus.BAD_REQUEST, "Branch cannot be found.", "branchBadReq");
}
if (user.length !== body.user.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
@ -139,8 +177,8 @@ export class BranchUserController extends Controller {
.map((v) => ({
branchId,
userId: v.id,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
})),
}),
]);
@ -249,8 +287,8 @@ export class UserBranchController extends Controller {
.map((v) => ({
branchId: v.id,
userId,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
})),
});

View file

@ -24,6 +24,15 @@ if (!process.env.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/profile-img-${id}`;
@ -40,6 +49,7 @@ export type CustomerBranchCreate = {
legalPersonNo: string;
branchNo: number;
taxNo: string | null;
name: string;
nameEN: string;
@ -105,9 +115,9 @@ export type CustomerBranchUpdate = {
@Route("api/v1/customer-branch")
@Tags("Customer Branch")
@Security("keycloak")
export class CustomerBranchController extends Controller {
@Get()
@Security("keycloak")
async list(
@Query() zipCode?: string,
@Query() customerId?: string,
@ -158,6 +168,8 @@ export class CustomerBranchController extends Controller {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
_count: true,
},
where,
@ -171,12 +183,15 @@ export class CustomerBranchController extends Controller {
}
@Get("{branchId}")
@Security("keycloak")
async getById(@Path() branchId: string) {
const record = await prisma.customerBranch.findFirst({
include: {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
where: { id: branchId },
});
@ -189,6 +204,7 @@ export class CustomerBranchController extends Controller {
}
@Get("{branchId}/employee")
@Security("keycloak")
async listEmployee(
@Path() branchId: string,
@Query() zipCode?: string,
@ -213,6 +229,8 @@ export class CustomerBranchController extends Controller {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
where,
take: pageSize,
@ -239,6 +257,7 @@ export class CustomerBranchController extends Controller {
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async create(@Request() req: RequestWithUser, @Body() body: CustomerBranchCreate) {
const [province, district, subDistrict, customer] = await prisma.$transaction([
prisma.province.findFirst({ where: { id: body.provinceId || undefined } }),
@ -275,8 +294,26 @@ export class CustomerBranchController extends Controller {
const record = await prisma.$transaction(
async (tx) => {
const count = await tx.customerBranch.count({
where: { customerId },
const conflict = await tx.customerBranch.findFirst({
where: { customerId, branchNo: rest.branchNo },
});
if (conflict) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch with current no already exists.",
"branchSameNoExist",
);
}
const last = await tx.runningNo.upsert({
where: {
key: `CUSTOMER_${customer.code.slice(0, -6)}`,
},
create: {
key: `CUSTOMER_${customer.code.slice(0, -6)}`,
value: 1,
},
update: { value: { increment: 1 } },
});
return await tx.customerBranch.create({
@ -284,35 +321,32 @@ export class CustomerBranchController extends Controller {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
data: {
...rest,
statusOrder: +(rest.status === "INACTIVE"),
branchNo: count + 1,
code: `${customer.code}-${(count + 1).toString().padStart(2, "0")}`,
code: `${customer.code.slice(0, -6)}${`${last.value - 1}`.padStart(6, "0")}`,
customer: { connect: { id: customerId } },
province: { connect: provinceId ? { id: provinceId } : undefined },
district: { connect: districtId ? { id: districtId } : undefined },
subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined },
createdBy: req.user.name,
updatedBy: req.user.name,
createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } },
},
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
await prisma.customer.updateMany({
where: { id: customerId, status: Status.CREATED },
data: { status: Status.ACTIVE },
});
this.setStatus(HttpStatus.CREATED);
return record;
}
@Put("{branchId}")
@Security("keycloak", MANAGE_ROLES)
async editById(
@Request() req: RequestWithUser,
@Body() body: CustomerBranchUpdate,
@ -363,6 +397,8 @@ export class CustomerBranchController extends Controller {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
data: {
...rest,
@ -380,8 +416,7 @@ export class CustomerBranchController extends Controller {
connect: subDistrictId ? { id: subDistrictId } : undefined,
disconnect: subDistrictId === null || undefined,
},
createdBy: req.user.name,
updatedBy: req.user.name,
updatedBy: { connect: { id: req.user.sub } },
},
});
@ -391,6 +426,7 @@ export class CustomerBranchController extends Controller {
}
@Delete("{branchId}")
@Security("keycloak", MANAGE_ROLES)
async delete(@Path() branchId: string) {
const record = await prisma.customerBranch.findFirst({
where: { id: branchId },
@ -412,27 +448,30 @@ export class CustomerBranchController extends Controller {
);
}
return await prisma.customerBranch.delete({ where: { id: branchId } }).then((v) => {
new Promise<string[]>((resolve, reject) => {
const item: string[] = [];
return await prisma.customerBranch
.delete({
include: { createdBy: true, updatedBy: true },
where: { id: branchId },
})
.then((v) => {
new Promise<string[]>((resolve, reject) => {
const item: string[] = [];
const stream = minio.listObjectsV2(
MINIO_BUCKET,
`${attachmentLocation(record.customerId, branchId)}/`,
);
const stream = minio.listObjectsV2(
MINIO_BUCKET,
`${attachmentLocation(record.customerId, branchId)}/`,
);
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,
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 });
});
});
return v;
});
return v;
});
}
}

View file

@ -24,8 +24,21 @@ if (!process.env.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",
];
export type CustomerCreate = {
registeredBranchId?: string;
code: string;
status?: Status;
personName: string;
personNameEN?: string;
@ -38,6 +51,7 @@ export type CustomerCreate = {
legalPersonNo: string;
branchNo: number;
taxNo: string | null;
name: string;
nameEN: string;
@ -68,6 +82,8 @@ export type CustomerCreate = {
};
export type CustomerUpdate = {
registeredBranchId?: string;
status?: "ACTIVE" | "INACTIVE";
personName?: string;
personNameEN?: string;
@ -82,6 +98,7 @@ export type CustomerUpdate = {
legalPersonNo: string;
branchNo: number;
taxNo: string | null;
name: string;
nameEN: string;
@ -117,9 +134,9 @@ function imageLocation(id: string) {
@Route("api/v1/customer")
@Tags("Customer")
@Security("keycloak")
export class CustomerController extends Controller {
@Get("type-stats")
@Security("keycloak")
async stat() {
const list = await prisma.customer.groupBy({
by: "customerType",
@ -139,6 +156,7 @@ export class CustomerController extends Controller {
}
@Get()
@Security("keycloak")
async list(
@Query() customerType?: CustomerType,
@Query() query: string = "",
@ -172,8 +190,13 @@ export class CustomerController extends Controller {
district: true,
subDistrict: true,
},
orderBy: {
branchNo: "asc",
},
}
: undefined,
createdBy: true,
updatedBy: true,
},
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
where,
@ -201,6 +224,7 @@ export class CustomerController extends Controller {
}
@Get("{customerId}")
@Security("keycloak")
async getById(@Path() customerId: string) {
const record = await prisma.customer.findFirst({
include: {
@ -210,7 +234,10 @@ export class CustomerController extends Controller {
district: true,
subDistrict: true,
},
orderBy: { branchNo: "asc" },
},
createdBy: true,
updatedBy: true,
},
where: { id: customerId },
});
@ -226,6 +253,7 @@ export class CustomerController extends Controller {
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async create(@Request() req: RequestWithUser, @Body() body: CustomerCreate) {
const { customerBranch, ...payload } = body;
@ -243,10 +271,11 @@ export class CustomerController extends Controller {
return acc;
}, []);
const [province, district, subDistrict] = await prisma.$transaction([
const [province, district, subDistrict, branch] = await prisma.$transaction([
prisma.province.findMany({ where: { id: { in: provinceId } } }),
prisma.district.findMany({ where: { id: { in: districtId } } }),
prisma.subDistrict.findMany({ where: { id: { in: subDistrictId } } }),
prisma.branch.findFirst({ where: { id: body.registeredBranchId } }),
]);
if (provinceId && province.length !== provinceId?.length) {
@ -270,20 +299,44 @@ export class CustomerController extends Controller {
"relationSubDistrictNotFound",
);
}
if (!!body.registeredBranchId && !branch) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch cannot be found.",
"relationBranchNotFound",
);
}
if (!body.registeredBranchId) {
body.registeredBranchId = undefined;
}
const record = await prisma.$transaction(
async (tx) => {
const last = await tx.runningNo.upsert({
where: {
key: `CUSTOMER_${body.customerType}`,
key: `CUSTOMER_${body.code.toLocaleUpperCase()}`,
},
create: {
key: `CUSTOMER_${body.customerType}`,
value: 1,
key: `CUSTOMER_${body.code.toLocaleUpperCase()}`,
value: body.customerBranch?.length || 0,
},
update: { value: { increment: 1 } },
update: { value: { increment: body.customerBranch?.length || 0 } },
});
body.code = body.code.toLocaleUpperCase();
const exist = await tx.customer.findFirst({
where: { code: `${body.code}000000` },
});
if (exist) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer with same code already exists.",
"sameCustomerCodeExists",
);
}
return await prisma.customer.create({
include: {
branch: {
@ -293,25 +346,26 @@ export class CustomerController extends Controller {
subDistrict: true,
},
},
createdBy: true,
updatedBy: true,
},
data: {
...payload,
statusOrder: +(payload.status === "INACTIVE"),
code: `${last.key.slice(9)}${last.value.toString().padStart(6, "0")}`,
code: `${body.code}000000`,
branch: {
createMany: {
data:
customerBranch?.map((v, i) => ({
...v,
branchNo: i + 1,
code: `${last.key.slice(9)}${last.value.toString().padStart(6, "0")}-${(i + 1).toString().padStart(2, "0")}`,
createdBy: req.user.name,
updatedBy: req.user.name,
code: `${body.code}${`${last.value - customerBranch?.length + i + 1}`.padStart(6, "0")}`,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
})) || [],
},
},
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
},
@ -335,6 +389,7 @@ export class CustomerController extends Controller {
}
@Put("{customerId}")
@Security("keycloak", MANAGE_ROLES)
async editById(
@Path() customerId: string,
@Request() req: RequestWithUser,
@ -407,6 +462,17 @@ export class CustomerController extends Controller {
);
}
if (
customerBranch &&
relation.find((a) => customerBranch.find((b) => a.id !== b.id && a.branchNo === b.branchNo))
) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch cannot have same number.",
"oneOrMoreBranchNoExist",
);
}
const record = await prisma.customer
.update({
include: {
@ -417,6 +483,8 @@ export class CustomerController extends Controller {
subDistrict: true,
},
},
createdBy: true,
updatedBy: true,
},
where: { id: customerId },
data: {
@ -430,26 +498,25 @@ export class CustomerController extends Controller {
},
status: Status.CREATED,
},
upsert: customerBranch.map((v, i) => ({
upsert: customerBranch.map((v) => ({
where: { id: v.id || "" },
create: {
...v,
branchNo: i + 1,
code: `${customer.code}-${(i + 1).toString().padStart(2, "0")}`,
createdBy: req.user.name,
updatedBy: req.user.name,
code: `${customer.code}-${v.branchNo.toString().padStart(2, "0")}`,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
id: undefined,
},
update: {
...v,
branchNo: i + 1,
code: `${customer.code}-${(i + 1).toString().padStart(2, "0")}`,
updatedBy: req.user.name,
code: undefined,
branchNo: undefined,
updatedByUserId: req.user.sub,
},
})),
}) ||
undefined,
updatedBy: req.user.name,
updatedByUserId: req.user.sub,
},
})
.then((v) => {
@ -492,6 +559,7 @@ export class CustomerController extends Controller {
}
@Delete("{customerId}")
@Security("keycloak", MANAGE_ROLES)
async deleteById(@Path() customerId: string) {
const record = await prisma.customer.findFirst({ where: { id: customerId } });
@ -522,4 +590,33 @@ export class CustomerController extends Controller {
return v;
});
}
@Get("{customerId}/image")
async getCustomerImageById(@Request() req: RequestWithUser, @Path() customerId: string) {
const url = await presignedGetObjectIfExist(MINIO_BUCKET, imageLocation(customerId), 60 * 60);
if (!url) {
throw new HttpError(HttpStatus.NOT_FOUND, "Image cannot be found", "imageNotFound");
}
return req.res?.redirect(url);
}
@Put("{customerId}/image")
@Security("keycloak", MANAGE_ROLES)
async setCustomerImageById(@Request() req: RequestWithUser, @Path() customerId: string) {
const record = await prisma.customer.findFirst({
where: {
id: customerId,
},
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "customerNotFound");
}
return req.res?.redirect(
await minio.presignedPutObject(MINIO_BUCKET, imageLocation(customerId), 12 * 60 * 60),
);
}
}

View file

@ -16,6 +16,16 @@ import prisma from "../db";
import HttpStatus from "../interfaces/http-status";
import HttpError from "../interfaces/http-error";
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"branch_admin",
"branch_manager",
"head_of_sale",
"sale",
];
type EmployeeCheckupPayload = {
checkupType?: string | null;
checkupResult?: string | null;
@ -32,19 +42,28 @@ type EmployeeCheckupPayload = {
@Route("api/v1/employee/{employeeId}/checkup")
@Tags("Employee Checkup")
@Security("keycloak")
export class EmployeeCheckupController extends Controller {
@Get()
@Security("keycloak")
async list(@Path() employeeId: string) {
return prisma.employeeCheckup.findMany({
include: {
createdBy: true,
updatedBy: true,
},
orderBy: { createdAt: "asc" },
where: { employeeId },
});
}
@Get("{checkupId}")
@Security("keycloak")
async getById(@Path() employeeId: string, @Path() checkupId: string) {
const record = await prisma.employeeCheckup.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: checkupId, employeeId },
});
if (!record) {
@ -58,6 +77,7 @@ export class EmployeeCheckupController extends Controller {
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async create(
@Request() req: RequestWithUser,
@Path() employeeId: string,
@ -85,13 +105,13 @@ export class EmployeeCheckupController extends Controller {
const { provinceId, ...rest } = body;
const record = await prisma.employeeCheckup.create({
include: { province: true },
include: { province: true, createdBy: true, updatedBy: true },
data: {
...rest,
province: { connect: provinceId ? { id: provinceId } : undefined },
employee: { connect: { id: employeeId } },
createdBy: req.user.name,
updatedBy: req.user.name,
createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } },
},
});
@ -101,6 +121,7 @@ export class EmployeeCheckupController extends Controller {
}
@Put("{checkupId}")
@Security("keycloak", MANAGE_ROLES)
async editById(
@Request() req: RequestWithUser,
@Path() employeeId: string,
@ -128,7 +149,12 @@ export class EmployeeCheckupController extends Controller {
const { provinceId, ...rest } = body;
if (!(await prisma.employeeCheckup.findUnique({ where: { id: checkupId, employeeId } }))) {
if (
!(await prisma.employeeCheckup.findUnique({
include: { createdBy: true, updatedBy: true },
where: { id: checkupId, employeeId },
}))
) {
throw new HttpError(
HttpStatus.NOT_FOUND,
"Employee checkup cannot be found.",
@ -137,13 +163,12 @@ export class EmployeeCheckupController extends Controller {
}
const record = await prisma.employeeCheckup.update({
include: { province: true },
include: { province: true, createdBy: true, updatedBy: true },
where: { id: checkupId, employeeId },
data: {
...rest,
province: { connect: provinceId ? { id: provinceId } : undefined },
createdBy: req.user.name,
updatedBy: req.user.name,
updatedBy: { connect: { id: req.user.sub } },
},
});
@ -153,6 +178,7 @@ export class EmployeeCheckupController extends Controller {
}
@Delete("{checkupId}")
@Security("keycloak", MANAGE_ROLES)
async deleteById(@Path() employeeId: string, @Path() checkupId: string) {
const record = await prisma.employeeCheckup.findFirst({ where: { id: checkupId, employeeId } });
@ -164,6 +190,9 @@ export class EmployeeCheckupController extends Controller {
);
}
return await prisma.employeeCheckup.delete({ where: { id: checkupId, employeeId } });
return await prisma.employeeCheckup.delete({
include: { createdBy: true, updatedBy: true },
where: { id: checkupId, employeeId },
});
}
}

View file

@ -24,6 +24,15 @@ if (!process.env.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`;
@ -34,7 +43,7 @@ type EmployeeCreate = {
status?: Status;
nrcNo: string;
nrcNo?: string;
dateOfBirth: Date;
gender: string;
@ -200,9 +209,9 @@ type EmployeeUpdate = {
@Route("api/v1/employee")
@Tags("Employee")
@Security("keycloak")
export class EmployeeController extends Controller {
@Get("stats")
@Security("keycloak")
async getEmployeeStats(@Query() customerBranchId?: string) {
return await prisma.employee.count({
where: { customerBranchId },
@ -210,12 +219,32 @@ export class EmployeeController extends Controller {
}
@Get("stats/gender")
async getEmployeeStatsGender(@Query() customerBranchId?: string) {
@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: { customerBranchId },
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<Record<string, number>>((a, c) => {
@ -226,6 +255,7 @@ export class EmployeeController extends Controller {
}
@Get()
@Security("keycloak")
async list(
@Query() zipCode?: string,
@Query() gender?: string,
@ -258,6 +288,11 @@ export class EmployeeController extends Controller {
province: true,
district: true,
subDistrict: true,
customerBranch: {
include: { customer: true },
},
createdBy: true,
updatedBy: true,
},
where,
take: pageSize,
@ -284,12 +319,18 @@ export class EmployeeController extends Controller {
}
@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 },
});
@ -298,10 +339,17 @@ export class EmployeeController extends Controller {
throw new HttpError(HttpStatus.NOT_FOUND, "Employee cannot be found.", "employeeNotFound");
}
return record;
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 } }),
@ -371,10 +419,10 @@ export class EmployeeController extends Controller {
async (tx) => {
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")}`,
key: `EMPLOYEE_${customerBranch.customer.code.slice(0, -6)}${`${new Date().getFullYear()}`.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")}`,
key: `EMPLOYEE_${customerBranch.customer.code.slice(0, -6)}${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}`,
value: 1,
},
update: { value: { increment: 1 } },
@ -392,11 +440,13 @@ export class EmployeeController extends Controller {
},
},
employeeWork: true,
createdBy: true,
updatedBy: true,
},
data: {
...rest,
statusOrder: +(rest.status === "INACTIVE"),
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")}`,
code: `${customerBranch.customer.code.slice(0, -6)}${`${new Date().getFullYear()}`.slice(-2).padStart(2, "0")}${`${last.value}`.padStart(7, "0")}`,
employeeWork: {
createMany: {
data: employeeWork || [],
@ -418,8 +468,8 @@ export class EmployeeController extends Controller {
district: { connect: districtId ? { id: districtId } : undefined },
subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined },
customerBranch: { connect: { id: customerBranchId } },
createdBy: req.user.name,
updatedBy: req.user.name,
createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } },
},
});
},
@ -458,6 +508,7 @@ export class EmployeeController extends Controller {
}
@Put("{employeeId}")
@Security("keycloak", MANAGE_ROLES)
async editById(
@Request() req: RequestWithUser,
@Body() body: EmployeeUpdate,
@ -534,7 +585,11 @@ export class EmployeeController extends Controller {
const record = await prisma.$transaction(async (tx) => {
let code: string | undefined;
if (customerBranch && customerBranch.id !== employee.customerBranchId) {
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")}`,
@ -561,6 +616,8 @@ export class EmployeeController extends Controller {
},
},
employeeWork: true,
createdBy: true,
updatedBy: true,
},
data: {
...rest,
@ -578,13 +635,13 @@ export class EmployeeController extends Controller {
where: { id: v.id || "" },
create: {
...v,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
id: undefined,
},
update: {
...v,
updatedBy: req.user.name,
updatedByUserId: req.user.sub,
},
})),
}
@ -602,21 +659,20 @@ export class EmployeeController extends Controller {
create: {
...v,
provinceId: !v.provinceId ? undefined : v.provinceId,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
id: undefined,
},
update: {
...v,
updatedBy: req.user.name,
updatedByUserId: req.user.sub,
},
})),
}
: undefined,
employeeOtherInfo: employeeOtherInfo
? {
deleteMany: {},
create: employeeOtherInfo,
update: employeeOtherInfo,
}
: undefined,
province: {
@ -631,8 +687,8 @@ export class EmployeeController extends Controller {
connect: subDistrictId ? { id: subDistrictId } : undefined,
disconnect: subDistrictId === null || undefined,
},
createdBy: req.user.name,
updatedBy: req.user.name,
createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } },
},
});
});
@ -663,7 +719,6 @@ export class EmployeeController extends Controller {
data: historyEntries.map((v) => ({
...v,
updatedByUserId: req.user.sub,
updatedBy: req.user.preferred_username,
masterId: employee.id,
})),
});
@ -683,6 +738,7 @@ export class EmployeeController extends Controller {
}
@Delete("{employeeId}")
@Security("keycloak", MANAGE_ROLES)
async delete(@Path() employeeId: string) {
const record = await prisma.employee.findFirst({ where: { id: employeeId } });
@ -694,12 +750,21 @@ export class EmployeeController extends Controller {
throw new HttpError(HttpStatus.FORBIDDEN, "Employee is in used.", "employeeInUsed");
}
return await prisma.employee.delete({ where: { id: employeeId } });
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 },
});
}

View file

@ -17,6 +17,16 @@ import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
import { RequestWithUser } from "../interfaces/user";
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"branch_admin",
"branch_manager",
"head_of_sale",
"sale",
];
type EmployeeOtherInfoPayload = {
citizenId?: string | null;
fatherFirstName?: string | null;
@ -34,35 +44,40 @@ type EmployeeOtherInfoPayload = {
@Route("api/v1/employee/{employeeId}/other-info")
@Tags("Employee Other Info")
@Security("keycloak")
export class EmployeeOtherInfo extends Controller {
@Get()
@Security("keycloak")
async list(@Path() employeeId: string) {
return prisma.employeeOtherInfo.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
orderBy: { createdAt: "asc" },
where: { employeeId },
});
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async create(
@Request() req: RequestWithUser,
@Path() employeeId: string,
@Body() body: EmployeeOtherInfoPayload,
) {
if (!(await prisma.employee.findUnique({ where: { id: employeeId } })))
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Employee cannot be found.",
"employeeBadReq",
);
throw new HttpError(HttpStatus.BAD_REQUEST, "Employee cannot be found.", "employeeBadReq");
const record = await prisma.employeeOtherInfo.create({
include: {
createdBy: true,
updatedBy: true,
},
data: {
...body,
employee: { connect: { id: employeeId } },
createdBy: req.user.name,
updatedBy: req.user.name,
createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } },
},
});
@ -72,6 +87,7 @@ export class EmployeeOtherInfo extends Controller {
}
@Put("{otherInfoId}")
@Security("keycloak", MANAGE_ROLES)
async editById(
@Request() req: RequestWithUser,
@Path() employeeId: string,
@ -87,8 +103,12 @@ export class EmployeeOtherInfo extends Controller {
}
const record = await prisma.employeeOtherInfo.update({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: otherInfoId, employeeId },
data: { ...body, createdBy: req.user.name, updatedBy: req.user.name },
data: { ...body, updatedByUserId: req.user.sub },
});
this.setStatus(HttpStatus.CREATED);
@ -97,6 +117,7 @@ export class EmployeeOtherInfo extends Controller {
}
@Delete("{otherInfoId}")
@Security("keycloak", MANAGE_ROLES)
async deleteById(@Path() employeeId: string, @Path() otherInfoId: string) {
const record = await prisma.employeeOtherInfo.findFirst({
where: { id: otherInfoId, employeeId },
@ -110,6 +131,12 @@ export class EmployeeOtherInfo extends Controller {
);
}
return await prisma.employeeOtherInfo.delete({ where: { id: otherInfoId, employeeId } });
return await prisma.employeeOtherInfo.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: otherInfoId, employeeId },
});
}
}

View file

@ -16,6 +16,16 @@ import prisma from "../db";
import HttpStatus from "../interfaces/http-status";
import HttpError from "../interfaces/http-error";
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"branch_admin",
"branch_manager",
"head_of_sale",
"sale",
];
type EmployeeWorkPayload = {
ownerName?: string | null;
positionName?: string | null;
@ -30,46 +40,60 @@ type EmployeeWorkPayload = {
@Route("api/v1/employee/{employeeId}/work")
@Tags("Employee Work")
@Security("keycloak")
export class EmployeeWorkController extends Controller {
@Get()
@Security("keycloak")
async list(@Path() employeeId: string) {
return prisma.employeeWork.findMany({
include: {
createdBy: true,
updatedBy: true,
},
orderBy: { createdAt: "asc" },
where: { employeeId },
});
}
@Get("{workId}")
@Security("keycloak")
async getById(@Path() employeeId: string, @Path() workId: string) {
const record = await prisma.employeeWork.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: workId, employeeId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Employee work cannot be found.", "employeeWorkNotFound");
throw new HttpError(
HttpStatus.NOT_FOUND,
"Employee work cannot be found.",
"employeeWorkNotFound",
);
}
return record;
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async create(
@Request() req: RequestWithUser,
@Path() employeeId: string,
@Body() body: EmployeeWorkPayload,
) {
if (!(await prisma.employee.findUnique({ where: { id: employeeId } })))
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Employee cannot be found.",
"employeeBadReq",
);
throw new HttpError(HttpStatus.BAD_REQUEST, "Employee cannot be found.", "employeeBadReq");
const record = await prisma.employeeWork.create({
include: {
createdBy: true,
updatedBy: true,
},
data: {
...body,
employee: { connect: { id: employeeId } },
createdBy: req.user.name,
updatedBy: req.user.name,
createdBy: { connect: { id: req.user.sub } },
updatedBy: { connect: { id: req.user.sub } },
},
});
@ -79,6 +103,7 @@ export class EmployeeWorkController extends Controller {
}
@Put("{workId}")
@Security("keycloak", MANAGE_ROLES)
async editById(
@Request() req: RequestWithUser,
@Path() employeeId: string,
@ -86,12 +111,20 @@ export class EmployeeWorkController extends Controller {
@Body() body: EmployeeWorkPayload,
) {
if (!(await prisma.employeeWork.findUnique({ where: { id: workId, employeeId } }))) {
throw new HttpError(HttpStatus.NOT_FOUND, "Employee work cannot be found.", "employeeWorkNotFound");
throw new HttpError(
HttpStatus.NOT_FOUND,
"Employee work cannot be found.",
"employeeWorkNotFound",
);
}
const record = await prisma.employeeWork.update({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: workId, employeeId },
data: { ...body, createdBy: req.user.name, updatedBy: req.user.name },
data: { ...body, updatedByUserId: req.user.sub },
});
this.setStatus(HttpStatus.CREATED);
@ -100,11 +133,22 @@ export class EmployeeWorkController extends Controller {
}
@Delete("{workId}")
@Security("keycloak", MANAGE_ROLES)
async deleteById(@Path() employeeId: string, @Path() workId: string) {
const record = await prisma.employeeWork.findFirst({ where: { id: workId, employeeId } });
const record = await prisma.employeeWork.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: workId, employeeId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Employee work cannot be found.", "employeeWorkNotFound");
throw new HttpError(
HttpStatus.NOT_FOUND,
"Employee work cannot be found.",
"employeeWorkNotFound",
);
}
return await prisma.employeeWork.delete({ where: { id: workId, employeeId } });

View file

@ -4,7 +4,7 @@ import {
createUser,
deleteUser,
editUser,
getRoles,
listRole,
removeUserRoles,
} from "../services/keycloak";
@ -38,18 +38,20 @@ export class KeycloakController extends Controller {
@Get("role")
async getRole() {
const role = await getRoles();
const role = await listRole();
if (Array.isArray(role))
return role.filter(
(a) =>
!["uma_authorization", "offline_access", "default-roles"].some((b) => a.name.includes(b)),
!["uma_authorization", "offline_access", "default-roles", "system"].some((b) =>
a.name.includes(b),
),
);
throw new Error("Failed. Cannot get role.");
}
@Post("{userId}/role")
async addRole(@Path() userId: string, @Body() body: { role: string[] }) {
const list = await getRoles();
const list = await listRole();
if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server.");
@ -63,7 +65,7 @@ export class KeycloakController extends Controller {
@Delete("{userId}/role/{roleId}")
async deleteRole(@Path() userId: string, @Path() roleId: string) {
const list = await getRoles();
const list = await listRole();
if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server.");

View file

@ -12,6 +12,7 @@ export class ProductServiceController extends Controller {
@Query() productTypeId?: string,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() registeredBranchId?: string,
) {
const union = Prisma.sql`
SELECT
@ -27,9 +28,10 @@ export class ProductServiceController extends Controller {
"status",
"statusOrder",
"productTypeId",
"createdBy",
"registeredBranchId",
"createdByUserId",
"createdAt",
"updatedBy",
"updatedByUserId",
"updatedAt",
'product' as "type"
FROM "Product"
@ -46,10 +48,11 @@ export class ProductServiceController extends Controller {
null as "remark",
"status",
"statusOrder",
null as "productTypeId",
"createdBy",
"productTypeId",
"registeredBranchId",
"createdByUserId",
"createdAt",
"updatedBy",
"updatedByUserId",
"updatedAt",
'service' as "type"
FROM "Service"
@ -61,7 +64,12 @@ export class ProductServiceController extends Controller {
if (query) or.push(Prisma.sql`"name" LIKE ${`%${query}%`}`);
if (status) and.push(Prisma.sql`"status" = ${status}::"Status"`);
if (productTypeId) {
and.push(Prisma.sql`("productTypeId" = ${productTypeId} OR ("type" = 'service'))`);
and.push(Prisma.sql`"productTypeId" = ${productTypeId}`);
}
if (registeredBranchId) {
and.push(
Prisma.sql`("registeredBranchId" = ${registeredBranchId} OR "registeredBranchId" IS NULL)`,
);
}
const where = Prisma.sql`
@ -70,6 +78,7 @@ export class ProductServiceController extends Controller {
${or.length > 0 && and.length > 0 ? Prisma.sql` AND ` : Prisma.empty}
${and.length > 0 ? Prisma.join(and, " AND ", "(", ")") : Prisma.empty}
`;
console.log(where.sql);
const [result, [{ total }]] = await prisma.$transaction([
prisma.$queryRaw<((Product & { type: "product" }) | (Service & { type: "service" }))[]>`

View file

@ -35,14 +35,15 @@ type ProductGroupUpdate = {
@Route("api/v1/product-group")
@Tags("Product Group")
@Security("keycloak")
export class ProductGroup extends Controller {
@Get("stats")
@Security("keycloak")
async getProductGroupStats() {
return await prisma.productGroup.count();
}
@Get()
@Security("keycloak")
async getProductGroup(
@Query() query: string = "",
@Query() status?: Status,
@ -72,6 +73,8 @@ export class ProductGroup extends Controller {
type: true,
},
},
createdBy: true,
updatedBy: true,
},
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
where,
@ -81,9 +84,9 @@ export class ProductGroup extends Controller {
prisma.productGroup.count({ where }),
]);
const statsProduct = await prisma.productType.findMany({
const statsDeep = await prisma.productType.findMany({
include: {
_count: { select: { product: true } },
_count: { select: { product: true, service: true } },
},
where: {
productGroupId: { in: result.map((v) => v.id) },
@ -95,10 +98,14 @@ export class ProductGroup extends Controller {
...v,
_count: {
...v._count,
product: statsProduct.reduce(
product: statsDeep.reduce(
(a, c) => (c.productGroupId === v.id ? a + c._count.product : a),
0,
),
service: statsDeep.reduce(
(a, c) => (c.productGroupId === v.id ? a + c._count.service : a),
0,
),
},
})),
page,
@ -108,6 +115,7 @@ export class ProductGroup extends Controller {
}
@Get("{groupId}")
@Security("keycloak")
async getProductGroupById(@Path() groupId: string) {
const record = await prisma.productGroup.findFirst({
where: { id: groupId },
@ -124,6 +132,7 @@ export class ProductGroup extends Controller {
}
@Post()
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_accountant", "accountant"])
async createProductGroup(@Request() req: RequestWithUser, @Body() body: ProductGroupCreate) {
const record = await prisma.$transaction(
async (tx) => {
@ -139,12 +148,16 @@ export class ProductGroup extends Controller {
});
return await tx.productGroup.create({
include: {
createdBy: true,
updatedBy: true,
},
data: {
...body,
statusOrder: +(body.status === "INACTIVE"),
code: `G${last.value.toString().padStart(2, "0")}`,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
},
@ -157,6 +170,7 @@ export class ProductGroup extends Controller {
}
@Put("{groupId}")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_accountant", "accountant"])
async editProductGroup(
@Request() req: RequestWithUser,
@Body() body: ProductGroupUpdate,
@ -171,7 +185,11 @@ export class ProductGroup extends Controller {
}
const record = await prisma.productGroup.update({
data: { ...body, statusOrder: +(body.status === "INACTIVE"), updatedBy: req.user.name },
include: {
createdBy: true,
updatedBy: true,
},
data: { ...body, statusOrder: +(body.status === "INACTIVE"), updatedByUserId: req.user.sub },
where: { id: groupId },
});
@ -179,6 +197,7 @@ export class ProductGroup extends Controller {
}
@Delete("{groupId}")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_accountant", "accountant"])
async deleteProductGroup(@Path() groupId: string) {
const record = await prisma.productGroup.findFirst({ where: { id: groupId } });
@ -194,6 +213,12 @@ export class ProductGroup extends Controller {
throw new HttpError(HttpStatus.FORBIDDEN, "Product group is in used.", "productGroupInUsed");
}
return await prisma.productGroup.delete({ where: { id: groupId } });
return await prisma.productGroup.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: groupId },
});
}
}

View file

@ -25,10 +25,33 @@ if (!process.env.MINIO_BUCKET) {
}
const MINIO_BUCKET = process.env.MINIO_BUCKET;
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"branch_admin",
"branch_manager",
"accountant",
"branch_accountant",
];
type ProductCreate = {
status?: Status;
code: "AC" | "DO" | "ac" | "do";
code:
| "DOE"
| "IMM"
| "TM"
| "HP"
| "MOUC"
| "MOUL"
| "AC"
| "doe"
| "imm"
| "tm"
| "hp"
| "mouc"
| "moul"
| "ac";
name: string;
detail: string;
process: number;
@ -37,6 +60,8 @@ type ProductCreate = {
serviceCharge: number;
productTypeId: string;
remark?: string;
registeredBranchId?: string;
};
type ProductUpdate = {
@ -49,12 +74,20 @@ type ProductUpdate = {
serviceCharge?: number;
remark?: string;
productTypeId?: string;
registeredBranchId?: string;
};
function imageLocation(id: string) {
return `product/${id}/image`;
}
function globalAllow(roles?: string[]) {
return ["system", "head_of_admin", "admin", "branch_admin", "branch_manager", "accountant"].some(
(v) => roles?.includes(v),
);
}
@Route("api/v1/product")
@Tags("Product")
export class ProductController extends Controller {
@ -71,6 +104,7 @@ export class ProductController extends Controller {
@Query() query: string = "",
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() registeredBranchId?: string,
) {
const filterStatus = (val?: Status) => {
if (!val) return {};
@ -85,10 +119,19 @@ export class ProductController extends Controller {
{ name: { contains: query }, productTypeId, ...filterStatus(status) },
{ detail: { contains: query }, productTypeId, ...filterStatus(status) },
],
AND: registeredBranchId
? {
OR: [{ registeredBranchId: registeredBranchId }, { registeredBranchId: null }],
}
: undefined,
} satisfies Prisma.ProductWhereInput;
const [result, total] = await prisma.$transaction([
prisma.product.findMany({
include: {
createdBy: true,
updatedBy: true,
},
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
where,
take: pageSize,
@ -118,6 +161,10 @@ export class ProductController extends Controller {
@Security("keycloak")
async getProductById(@Path() productId: string) {
const record = await prisma.product.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: productId },
});
@ -142,11 +189,29 @@ export class ProductController extends Controller {
}
@Post()
@Security("keycloak")
@Security("keycloak", MANAGE_ROLES)
async createProduct(@Request() req: RequestWithUser, @Body() body: ProductCreate) {
const productType = await prisma.productType.findFirst({
where: { id: body.productTypeId },
});
const [productType, branch] = await prisma.$transaction([
prisma.productType.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: body.productTypeId },
}),
prisma.branch.findFirst({
include: { user: { where: { userId: req.user.sub } } },
where: { id: body.registeredBranchId },
}),
]);
if (!globalAllow(req.user.roles) && !branch?.user.find((v) => v.userId === req.user.sub)) {
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
if (!productType) {
throw new HttpError(
@ -156,6 +221,14 @@ export class ProductController extends Controller {
);
}
if (body.registeredBranchId && !branch) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch cannot be found.",
"relationBranchNotFound",
);
}
const record = await prisma.$transaction(
async (tx) => {
const last = await tx.runningNo.upsert({
@ -169,12 +242,16 @@ export class ProductController extends Controller {
update: { value: { increment: 1 } },
});
return await prisma.product.create({
include: {
createdBy: true,
updatedBy: true,
},
data: {
...body,
statusOrder: +(body.status === "INACTIVE"),
code: `${body.code.toLocaleUpperCase()}${last.value.toString().padStart(3, "0")}`,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
},
@ -185,6 +262,10 @@ export class ProductController extends Controller {
if (productType.status === "CREATED") {
await prisma.productType.update({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: body.productTypeId },
data: { status: Status.ACTIVE },
});
@ -207,19 +288,44 @@ export class ProductController extends Controller {
}
@Put("{productId}")
@Security("keycloak")
@Security("keycloak", MANAGE_ROLES)
async editProduct(
@Request() req: RequestWithUser,
@Body() body: ProductUpdate,
@Path() productId: string,
) {
if (!(await prisma.product.findUnique({ where: { id: productId } }))) {
const [product, productType, branch] = await prisma.$transaction([
prisma.product.findUnique({
include: {
registeredBranch: {
where: {
user: { some: { userId: req.user.sub } },
},
},
},
where: { id: productId },
}),
prisma.productType.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: body.productTypeId },
}),
prisma.branch.findFirst({ where: { id: body.registeredBranchId } }),
]);
if (!product) {
throw new HttpError(HttpStatus.NOT_FOUND, "Product cannot be found.", "productNotFound");
}
const productType = await prisma.productType.findFirst({
where: { id: body.productTypeId },
});
if (!globalAllow(req.user.roles) && !product.registeredBranch) {
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
if (!productType) {
throw new HttpError(
@ -229,8 +335,20 @@ export class ProductController extends Controller {
);
}
if (body.registeredBranchId && !branch) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch cannot be found.",
"relationBranchNotFound",
);
}
const record = await prisma.product.update({
data: { ...body, statusOrder: +(body.status === "INACTIVE"), updatedBy: req.user.name },
include: {
createdBy: true,
updatedBy: true,
},
data: { ...body, statusOrder: +(body.status === "INACTIVE"), updatedByUserId: req.user.sub },
where: { id: productId },
});
@ -256,18 +374,42 @@ export class ProductController extends Controller {
}
@Delete("{productId}")
@Security("keycloak")
async deleteProduct(@Path() productId: string) {
const record = await prisma.product.findFirst({ where: { id: productId } });
@Security("keycloak", MANAGE_ROLES)
async deleteProduct(@Request() req: RequestWithUser, @Path() productId: string) {
const record = await prisma.product.findFirst({
include: {
registeredBranch: {
where: {
user: { some: { userId: req.user.sub } },
},
},
},
where: { id: productId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Product cannot be found.", "productNotFound");
}
if (!globalAllow(req.user.roles) && !record.registeredBranch) {
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
if (record.status !== Status.CREATED) {
throw new HttpError(HttpStatus.FORBIDDEN, "Product is in used.", "productInUsed");
}
return await prisma.product.delete({ where: { id: productId } });
return await prisma.product.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: productId },
});
}
}

View file

@ -37,7 +37,6 @@ type ProductTypeUpdate = {
@Route("api/v1/product-type")
@Tags("Product Type")
@Security("keycloak")
export class ProductType extends Controller {
@Get("stats")
async getProductTypeStats() {
@ -45,6 +44,7 @@ export class ProductType extends Controller {
}
@Get()
@Security("keycloak")
async getProductType(
@Query() query: string = "",
@Query() productGroupId?: string,
@ -70,8 +70,13 @@ export class ProductType extends Controller {
prisma.productType.findMany({
include: {
_count: {
select: { product: true },
select: {
product: true,
service: true,
},
},
createdBy: true,
updatedBy: true,
},
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
where,
@ -85,6 +90,7 @@ export class ProductType extends Controller {
}
@Get("{typeId}")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_accountant", "accountant"])
async getProductTypeById(@Path() typeId: string) {
const record = await prisma.productType.findFirst({
where: { id: typeId },
@ -101,6 +107,7 @@ export class ProductType extends Controller {
}
@Post()
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_accountant", "accountant"])
async createProductType(@Request() req: RequestWithUser, @Body() body: ProductTypeCreate) {
const productGroup = await prisma.productGroup.findFirst({
where: { id: body.productGroupId },
@ -128,12 +135,16 @@ export class ProductType extends Controller {
});
return await tx.productType.create({
include: {
createdBy: true,
updatedBy: true,
},
data: {
...body,
statusOrder: +(body.status === "INACTIVE"),
code: `T${productGroup.code}${last.value.toString().padStart(2, "0")}`,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
},
@ -153,6 +164,7 @@ export class ProductType extends Controller {
}
@Put("{typeId}")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_accountant", "accountant"])
async editProductType(
@Request() req: RequestWithUser,
@Body() body: ProductTypeUpdate,
@ -178,12 +190,20 @@ export class ProductType extends Controller {
}
const record = await prisma.productType.update({
data: { ...body, statusOrder: +(body.status === "INACTIVE"), updatedBy: req.user.name },
include: {
createdBy: true,
updatedBy: true,
},
data: {
...body,
statusOrder: +(body.status === "INACTIVE"),
updatedByUserId: req.user.sub,
},
where: { id: typeId },
});
if (productGroup?.status === "CREATED") {
await prisma.productGroup.update({
if (body.productGroupId && productGroup?.status === "CREATED") {
await prisma.productGroup.updateMany({
where: { id: body.productGroupId, status: Status.CREATED },
data: { status: Status.ACTIVE },
});
@ -193,6 +213,7 @@ export class ProductType extends Controller {
}
@Delete("{typeId}")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_accountant", "accountant"])
async deleteProductType(@Path() typeId: string) {
const record = await prisma.productType.findFirst({ where: { id: typeId } });
@ -208,6 +229,12 @@ export class ProductType extends Controller {
throw new HttpError(HttpStatus.FORBIDDEN, "Product type is in used.", "productTypeInUsed");
}
return await prisma.productType.delete({ where: { id: typeId } });
return await prisma.productType.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: typeId },
});
}
}

View file

@ -0,0 +1,832 @@
import { PayCondition, 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 HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
type QuotationCreate = {
status?: Status;
payCondition: PayCondition;
paySplitCount?: number;
paySplit?: Date[];
payBillDate?: Date;
workerCount: number;
// EmployeeId or Create new employee
worker: (
| 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;
}
)[];
customerBranchId: string;
customerId: string;
urgent?: boolean;
service: {
id: string;
// Other fields will come from original data
work: {
id: string;
// Name field will come from original data
excluded?: boolean;
product: {
id: string;
amount: number;
/**
* @maximum 1
* @minimum 0
*/
discount: number;
/**
* @maximum 1
* @minimum 0
*/
vat?: number;
}[];
}[];
}[];
};
type QuotationUpdate = {
status?: "ACTIVE" | "INACTIVE";
payCondition?: PayCondition;
paySplitCount?: number;
paySplit?: Date[];
payBillDate?: Date;
workerCount?: number;
// EmployeeId or Create new employee
worker?: (
| 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;
}
)[];
customerBranchId?: string;
customerId?: string;
urgent?: boolean;
service?: {
id: string;
// Other fields will come from original data
work: {
id: string;
excluded?: boolean;
// Name field will come from original data
product: {
id: string;
/**
* @isInt
*/
amount: number;
/**
* @maximum 1
* @minimum 0
*/
discount: number;
/**
* @maximum 1
* @minimum 0
*/
vat: number;
}[];
}[];
}[];
};
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"branch_admin",
"branch_manager",
"accountant",
"branch_accountant",
];
function globalAllow(roles?: string[]) {
return ["system", "head_of_admin", "admin", "branch_admin", "branch_manager", "accountant"].some(
(v) => roles?.includes(v),
);
}
@Route("/api/v1/quotation")
@Tags("Quotation")
export class QuotationController extends Controller {
@Get()
@Security("keycloak")
async getQuotationList(@Query() page: number = 1, @Query() pageSize: number = 30) {
const [result, total] = await prisma.$transaction([
prisma.quotation.findMany({
include: {
worker: true,
service: {
include: {
_count: { select: { work: true } },
work: {
include: {
_count: { select: { productOnWork: true } },
productOnWork: {
include: { product: true },
},
},
},
},
},
},
}),
prisma.quotation.count(),
]);
return { result: result, page, pageSize, total };
}
@Get("{quotationId}")
@Security("keycloak")
async getQuotationById(@Path() quotationId: string) {
const record = await prisma.quotation.findUnique({
include: {
worker: true,
service: {
include: {
_count: { select: { work: true } },
work: {
include: {
_count: { select: { productOnWork: true } },
productOnWork: {
include: { product: true },
},
},
},
},
},
},
where: { id: quotationId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound");
}
return record;
}
@Post()
@Security("keycloak", MANAGE_ROLES)
async createQuotation(@Request() req: RequestWithUser, @Body() body: QuotationCreate) {
const existingEmployee = body.worker.filter((v) => typeof v === "string");
const serviceIdList = body.service.map((v) => v.id);
const productIdList = body.service.flatMap((a) =>
a.work.flatMap((b) => b.product.map((c) => c.id)),
);
const [customer, customerBranch, employee, service, product] = await prisma.$transaction([
prisma.customer.findUnique({
where: { id: body.customerId },
}),
prisma.customerBranch.findUnique({
include: { customer: true },
where: { id: body.customerBranchId },
}),
prisma.employee.findMany({
where: { id: { in: existingEmployee } },
}),
prisma.service.findMany({
include: { work: true },
where: { id: { in: serviceIdList } },
}),
prisma.product.findMany({
where: { id: { in: productIdList } },
}),
]);
if (serviceIdList.length !== service.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some service cannot be found.",
"relationServiceNotFound",
);
}
if (productIdList.length !== product.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some product cannot be found.",
"relationProductNotFound",
);
}
if (existingEmployee.length !== employee.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some worker(employee) cannot be found.",
"relationWorkerNotFound",
);
}
if (!customer)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer cannot be found.",
"relationCustomerNotFound",
);
if (!customerBranch)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer Branch cannot be found.",
"relationCustomerBranchNotFound",
);
if (customerBranch.customerId !== customer.id)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer conflict with customer branch.",
"customerConflictCustomerBranch",
);
const { service: _service, worker: _worker, ...rest } = body;
return await prisma.$transaction(async (tx) => {
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
const lastEmployee = 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: nonExistEmployee.length } },
});
const newEmployee = await Promise.all(
nonExistEmployee.map(async (v, i) =>
tx.employee.create({
data: {
...v,
code: `${customerBranch.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}${(lastEmployee.value + i).toString().padStart(4, "0")}`,
customerBranchId: customerBranch.id,
},
}),
),
);
const sortedEmployeeId: string[] = [];
while (body.worker.length > 0) {
const popExist = body.worker.shift();
if (typeof popExist === "string") sortedEmployeeId.push(popExist);
else {
const popNew = newEmployee.shift();
popNew && sortedEmployeeId.push(popNew.id);
}
}
const price = { totalPrice: 0, totalDiscount: 0, totalVat: 0 };
const restructureService = body.service.flatMap((a) => {
const currentService = service.find((b) => b.id === a.id);
if (!currentService) return []; // should not possible
return {
id: currentService.id,
name: currentService.name,
code: currentService.code,
detail: currentService.detail,
attributes: currentService.attributes as Prisma.JsonObject,
work: a.work.flatMap((c) => {
if (c.excluded) return [];
const currentWork = currentService.work.find((d) => d.id === c.id);
if (!currentWork) return []; // additional will get stripped
return {
id: currentWork.id,
order: currentWork.order,
name: currentWork.name,
attributes: currentWork.attributes as Prisma.JsonObject,
product: c.product.flatMap((e) => {
const currentProduct = product.find((f) => f.id === e.id);
if (!currentProduct) return []; // should not possible
price.totalPrice += currentProduct.price * e.amount;
price.totalDiscount +=
Math.round(currentProduct.price * e.amount * e.discount * 100) / 100;
price.totalVat +=
Math.round(
(currentProduct.price * e.amount -
currentProduct.price * e.amount * e.discount) *
(e.vat === undefined ? 0.07 : e.vat) *
100,
) / 100;
return {
...e,
vat: e.vat === undefined ? 0.07 : e.vat,
pricePerUnit: currentProduct.price,
};
}),
};
}),
};
});
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const currentDate = new Date().getDate();
const lastQuotation = await tx.runningNo.upsert({
where: {
key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}`,
},
create: {
key: `QUOTATION_${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}`,
value: 1,
},
update: { value: { increment: 1 } },
});
const quotation = await tx.quotation.create({
data: {
...rest,
statusOrder: +(rest.status === "INACTIVE"),
code: `${currentYear.toString().padStart(2, "0")}${currentMonth.toString().padStart(2, "0")}${currentDate.toString().padStart(2, "0")}${lastQuotation.value.toString().padStart(4, "0")}`,
worker: {
createMany: {
data: sortedEmployeeId.map((v, i) => ({
no: i,
code: "",
employeeId: v,
})),
},
},
totalPrice: price.totalPrice,
totalDiscount: price.totalDiscount,
vat: price.totalVat,
vatExcluded: 0,
finalPrice: price.totalPrice - price.totalDiscount,
paySplit: {
createMany: {
data: (rest.paySplit || []).map((v, i) => ({
no: i + 1,
date: v,
})),
},
},
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
await Promise.all(
restructureService.map(async (a) => {
const { id: _currentServiceId } = await tx.quotationService.create({
data: {
code: a.code,
name: a.name,
detail: a.detail,
attributes: a.attributes,
quotationId: quotation.id,
refServiceId: a.id,
},
});
await Promise.all(
a.work.map(async (b) => {
await tx.quotationServiceWork.create({
data: {
order: b.order,
name: b.name,
attributes: b.attributes,
serviceId: _currentServiceId,
productOnWork: {
createMany: {
data: b.product.map((v, i) => ({
productId: v.id,
order: i + 1,
vat: v.vat,
amount: v.amount,
discount: v.discount,
pricePerUnit: v.pricePerUnit,
})),
},
},
},
});
}),
);
}),
);
return await tx.quotation.findUnique({
include: {
service: {
include: {
work: {
include: {
productOnWork: {
include: { product: true },
},
},
},
},
},
paySplit: true,
worker: true,
customerBranch: {
include: { customer: true },
},
_count: {
select: { service: true },
},
},
where: { id: quotation.id },
});
});
}
@Put("{quotationId}")
@Security("keycloak", MANAGE_ROLES)
async editQuotation(
@Request() req: RequestWithUser,
@Path() quotationId: string,
@Body() body: QuotationUpdate,
) {
const record = await prisma.quotation.findUnique({
include: { customer: true },
where: { id: quotationId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound");
}
const existingEmployee = body.worker?.filter((v) => typeof v === "string");
const serviceIdList = body.service?.map((v) => v.id);
const productIdList = body.service?.flatMap((a) =>
a.work.flatMap((b) => b.product.map((c) => c.id)),
);
const [customer, customerBranch, employee, service, product] = await prisma.$transaction(
async (tx) =>
await Promise.all([
tx.customer.findFirst({
where: { id: body.customerId },
}),
tx.customerBranch.findFirst({
include: { customer: true },
where: { id: body.customerBranchId },
}),
body.worker
? tx.employee.findMany({
where: { id: { in: existingEmployee } },
})
: null,
body.service
? tx.service.findMany({
include: { work: true },
where: { id: { in: serviceIdList } },
})
: null,
body.service
? tx.product.findMany({
where: { id: { in: productIdList } },
})
: null,
]),
);
if (serviceIdList?.length !== service?.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some service cannot be found.",
"relationServiceNotFound",
);
}
if (productIdList?.length !== product?.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some product cannot be found.",
"relationProductNotFound",
);
}
if (existingEmployee?.length !== employee?.length) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Some worker(employee) cannot be found.",
"relationWorkerNotFound",
);
}
if (!customer)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer cannot be found.",
"relationCustomerNotFound",
);
if (!customerBranch)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer Branch cannot be found.",
"relationCustomerBranchNotFound",
);
if (customerBranch.customerId !== customer.id)
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Customer conflict with customer branch.",
"customerConflictCustomerBranch",
);
const { service: _service, worker: _worker, ...rest } = body;
return await prisma.$transaction(async (tx) => {
const sortedEmployeeId: string[] = [];
if (body.worker) {
const nonExistEmployee = body.worker.filter((v) => typeof v !== "string");
const lastEmployee = 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: nonExistEmployee.length } },
});
const newEmployee = await Promise.all(
nonExistEmployee.map(async (v, i) =>
tx.employee.create({
data: {
...v,
code: `${customerBranch.customer.code}-${customerBranch.branchNo.toString().padStart(2, "0")}-${new Date().getFullYear().toString().slice(-2).padStart(2, "0")}${(lastEmployee.value + i).toString().padStart(4, "0")}`,
customerBranchId: customerBranch.id,
},
}),
),
);
while (body.worker.length > 0) {
const popExist = body.worker.shift();
if (typeof popExist === "string") sortedEmployeeId.push(popExist);
else {
const popNew = newEmployee.shift();
popNew && sortedEmployeeId.push(popNew.id);
}
}
}
const price = { totalPrice: 0, totalDiscount: 0, totalVat: 0 };
const restructureService = body.service?.flatMap((a) => {
const currentService = service?.find((b) => b.id === a.id);
if (!currentService) return []; // should not possible
return {
id: currentService.id,
name: currentService.name,
code: currentService.code,
detail: currentService.detail,
attributes: currentService.attributes as Prisma.JsonObject,
work: a.work.flatMap((c) => {
if (c.excluded) return [];
const currentWork = currentService.work.find((d) => d.id === c.id);
if (!currentWork) return []; // additional will get stripped
return {
id: currentWork.id,
order: currentWork.order,
name: currentWork.name,
attributes: currentWork.attributes as Prisma.JsonObject,
product: c.product.flatMap((e) => {
const currentProduct = product?.find((f) => f.id === e.id);
if (!currentProduct) return []; // should not possible
price.totalPrice += currentProduct.price * e.amount;
price.totalDiscount +=
Math.round(currentProduct.price * e.amount * e.discount * 100) / 100;
price.totalVat +=
Math.round(
(currentProduct.price * e.amount -
currentProduct.price * e.amount * e.discount) *
(e.vat === undefined ? 0.07 : e.vat) *
100,
) / 100;
return {
...e,
vat: e.vat === undefined ? 0.07 : e.vat,
pricePerUnit: currentProduct.price,
};
}),
};
}),
};
});
const quotation = await tx.quotation.update({
where: { id: quotationId },
data: {
...rest,
statusOrder: +(rest.status === "INACTIVE"),
code: "",
worker:
sortedEmployeeId.length > 0
? {
deleteMany: { id: { notIn: sortedEmployeeId } },
createMany: {
skipDuplicates: true,
data: sortedEmployeeId.map((v, i) => ({
no: i,
code: "",
employeeId: v,
})),
},
}
: undefined,
totalPrice: body.service ? price.totalPrice : undefined,
totalDiscount: body.service ? price.totalDiscount : undefined,
vat: body.service ? price.totalVat : undefined,
vatExcluded: body.service ? 0 : undefined,
finalPrice: body.service ? price.totalPrice - price.totalDiscount : undefined,
paySplit: rest.paySplit
? {
deleteMany: {},
createMany: {
data: (rest.paySplit || []).map((v, i) => ({
no: i + 1,
date: v,
})),
},
}
: undefined,
service: body.service ? { deleteMany: {} } : undefined,
updatedByUserId: req.user.sub,
},
});
if (restructureService) {
await Promise.all(
restructureService.map(async (a) => {
const { id: _currentServiceId } = await tx.quotationService.create({
data: {
code: a.code,
name: a.name,
detail: a.detail,
attributes: a.attributes,
quotationId: quotation.id,
refServiceId: a.id,
},
});
await Promise.all(
a.work.map(async (b) => {
await tx.quotationServiceWork.create({
data: {
order: b.order,
name: b.name,
attributes: b.attributes,
serviceId: _currentServiceId,
productOnWork: {
createMany: {
data: b.product.map((v, i) => ({
productId: v.id,
order: i + 1,
vat: v.vat,
amount: v.amount,
discount: v.discount,
pricePerUnit: v.pricePerUnit,
})),
},
},
},
});
}),
);
}),
);
}
return await tx.quotation.findUnique({
include: {
service: {
include: {
work: {
include: {
productOnWork: {
include: { product: true },
},
},
},
},
},
paySplit: true,
worker: true,
customerBranch: {
include: { customer: true },
},
_count: {
select: { service: true },
},
},
where: { id: quotation.id },
});
});
}
@Delete("{quotationId}")
@Security("keycloak", MANAGE_ROLES)
async deleteQuotationById(@Path() quotationId: string) {
const record = await prisma.quotation.findUnique({
where: { id: quotationId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Quotation not found.", "quotationNotFound");
}
if (record.status !== Status.CREATED) {
throw new HttpError(HttpStatus.FORBIDDEN, "Quotation is in used.", "quotationInUsed");
}
return await prisma.quotation.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: quotationId },
});
}
}

View file

@ -25,6 +25,15 @@ if (!process.env.MINIO_BUCKET) {
}
const MINIO_BUCKET = process.env.MINIO_BUCKET;
const MANAGE_ROLES = [
"system",
"head_of_admin",
"admin",
"branch_admin",
"branch_manager",
"accountant",
"branch_accountant",
];
type ServiceCreate = {
code: "MOU" | "mou";
@ -39,6 +48,8 @@ type ServiceCreate = {
productId: string[];
attributes?: { [key: string]: any };
}[];
productTypeId: string;
registeredBranchId?: string;
};
type ServiceUpdate = {
@ -53,12 +64,18 @@ type ServiceUpdate = {
productId: string[];
attributes?: { [key: string]: any };
}[];
productTypeId?: string;
registeredBranchId?: string;
};
function imageLocation(id: string) {
return `service/${id}/service-image`;
}
function globalAllow(roles?: string[]) {
return ["system", "head_of_admin", "admin", "accountant"].some((v) => roles?.includes(v));
}
@Route("api/v1/service")
@Tags("Service")
export class ServiceController extends Controller {
@ -75,6 +92,9 @@ export class ServiceController extends Controller {
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() status?: Status,
@Query() productTypeId?: string,
@Query() registeredBranchId?: string,
@Query() fullDetail?: boolean,
) {
const filterStatus = (val?: Status) => {
if (!val) return {};
@ -86,15 +106,32 @@ export class ServiceController extends Controller {
const where = {
OR: [
{ name: { contains: query }, ...filterStatus(status) },
{ detail: { contains: query }, ...filterStatus(status) },
{ name: { contains: query }, productTypeId, ...filterStatus(status) },
{ detail: { contains: query }, productTypeId, ...filterStatus(status) },
],
AND: registeredBranchId
? {
OR: [{ registeredBranchId: registeredBranchId }, { registeredBranchId: null }],
}
: undefined,
} satisfies Prisma.ServiceWhereInput;
const [result, total] = await prisma.$transaction([
prisma.service.findMany({
include: {
work: true,
work: fullDetail
? {
orderBy: { order: "asc" },
include: {
productOnWork: {
include: { product: true },
orderBy: { order: "asc" },
},
},
}
: true,
createdBy: true,
updatedBy: true,
},
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
where,
@ -135,6 +172,8 @@ export class ServiceController extends Controller {
},
},
},
createdBy: true,
updatedBy: true,
},
where: { id: serviceId },
});
@ -168,6 +207,8 @@ export class ServiceController extends Controller {
},
orderBy: { order: "asc" },
},
createdBy: true,
updatedBy: true,
},
where,
take: pageSize,
@ -190,9 +231,47 @@ export class ServiceController extends Controller {
}
@Post()
@Security("keycloak")
@Security("keycloak", MANAGE_ROLES)
async createService(@Request() req: RequestWithUser, @Body() body: ServiceCreate) {
const { work, ...payload } = body;
const { work, productTypeId, ...payload } = body;
const [productType, branch] = await prisma.$transaction([
prisma.productType.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: body.productTypeId },
}),
prisma.branch.findFirst({
include: { user: { where: { userId: req.user.sub } } },
where: { id: body.registeredBranchId },
}),
]);
if (!globalAllow(req.user.roles) && !branch?.user.find((v) => v.userId === req.user.sub)) {
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
if (!productType) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Product Type cannot be found.",
"relationProductTypeNotFound",
);
}
if (body.registeredBranchId && !branch) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch cannot be found.",
"relationBranchNotFound",
);
}
const record = await prisma.$transaction(
async (tx) => {
@ -239,14 +318,17 @@ export class ServiceController extends Controller {
},
},
},
createdBy: true,
updatedBy: true,
},
data: {
...payload,
productTypeId,
statusOrder: +(body.status === "INACTIVE"),
code: `${body.code.toLocaleUpperCase()}${last.value.toString().padStart(3, "0")}`,
work: { connect: workList.map((v) => ({ id: v.id })) },
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
},
@ -269,7 +351,7 @@ export class ServiceController extends Controller {
}
@Put("{serviceId}")
@Security("keycloak")
@Security("keycloak", MANAGE_ROLES)
async editService(
@Request() req: RequestWithUser,
@Body() body: ServiceUpdate,
@ -278,7 +360,57 @@ export class ServiceController extends Controller {
if (!(await prisma.service.findUnique({ where: { id: serviceId } }))) {
throw new HttpError(HttpStatus.NOT_FOUND, "Service cannot be found.", "serviceNotFound");
}
const { work, ...payload } = body;
const { work, productTypeId, ...payload } = body;
const [service, productType, branch] = await prisma.$transaction([
prisma.service.findUnique({
include: {
registeredBranch: {
where: {
user: { some: { userId: req.user.sub } },
},
},
},
where: { id: serviceId },
}),
prisma.productType.findFirst({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: body.productTypeId },
}),
prisma.branch.findFirst({ where: { id: body.registeredBranchId } }),
]);
if (!service) {
throw new HttpError(HttpStatus.NOT_FOUND, "Service cannot be found.", "serviceNotFound");
}
if (!globalAllow(req.user.roles) && !service.registeredBranch) {
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
if (!productType) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Product Type cannot be found.",
"relationProductTypeNotFound",
);
}
if (body.registeredBranchId && !branch) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Branch cannot be found.",
"relationBranchNotFound",
);
}
const record = await prisma.$transaction(async (tx) => {
const workList = await Promise.all(
(work || []).map(async (w, wIdx) =>
@ -301,6 +433,10 @@ export class ServiceController extends Controller {
);
return await tx.service.update({
include: {
createdBy: true,
updatedBy: true,
},
data: {
...payload,
statusOrder: +(payload.status === "INACTIVE"),
@ -308,7 +444,7 @@ export class ServiceController extends Controller {
deleteMany: {},
connect: workList.map((v) => ({ id: v.id })),
},
updatedBy: req.user.name,
updatedByUserId: req.user.sub,
},
where: { id: serviceId },
});
@ -329,18 +465,41 @@ export class ServiceController extends Controller {
}
@Delete("{serviceId}")
@Security("keycloak")
async deleteService(@Path() serviceId: string) {
const record = await prisma.service.findFirst({ where: { id: serviceId } });
@Security("keycloak", MANAGE_ROLES)
async deleteService(@Request() req: RequestWithUser, @Path() serviceId: string) {
const record = await prisma.service.findFirst({
include: {
registeredBranch: {
where: {
user: { some: { userId: req.user.sub } },
},
},
},
where: { id: serviceId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Service cannot be found.", "serviceNotFound");
}
if (!globalAllow(req.user.roles) && !record.registeredBranch) {
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to perform this action.",
"noPermission",
);
}
if (record.status !== Status.CREATED) {
throw new HttpError(HttpStatus.FORBIDDEN, "Service is in used.", "serviceInUsed");
}
return await prisma.service.delete({ where: { id: serviceId } });
return await prisma.service.delete({
include: {
createdBy: true,
updatedBy: true,
},
where: { id: serviceId },
});
}
}

View file

@ -12,7 +12,7 @@ import {
Security,
Tags,
} from "tsoa";
import { Prisma, Status, UserType } from "@prisma/client";
import { Branch, Prisma, Status, User, UserType } from "@prisma/client";
import prisma from "../db";
import minio, { presignedGetObjectIfExist } from "../services/minio";
@ -24,7 +24,7 @@ import {
createUser,
deleteUser,
editUser,
getRoles,
listRole,
getUserRoles,
removeUserRoles,
} from "../services/keycloak";
@ -43,8 +43,11 @@ type UserCreate = {
username: string;
namePrefix?: string | null;
firstName: string;
firstNameEN: string;
middleName?: string | null;
middleNameEN?: string | null;
lastName: string;
lastNameEN: string;
gender: string;
@ -73,6 +76,8 @@ type UserCreate = {
subDistrictId?: string | null;
districtId?: string | null;
provinceId?: string | null;
branchId: string | string[];
};
type UserUpdate = {
@ -83,8 +88,11 @@ type UserUpdate = {
userType?: UserType;
userRole?: string;
namePrefix?: string | null;
firstName?: string;
firstNameEN?: string;
middleName?: string | null;
middleNameEN?: string | null;
lastName?: string;
lastNameEN?: string;
gender?: string;
@ -113,8 +121,44 @@ type UserUpdate = {
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, "0"),
},
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
}
function imageLocation(id: string) {
return `user/profile-img-${id}`;
}
@ -153,16 +197,28 @@ export class UserController extends Controller {
@Query() query: string = "",
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() status?: Status,
) {
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, 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 },
{ firstName: { contains: query }, zipCode, userType, ...filterStatus(status) },
{ firstNameEN: { contains: query }, zipCode, userType, ...filterStatus(status) },
{ lastName: { contains: query }, zipCode, userType, ...filterStatus(status) },
{ lastNameEN: { contains: query }, zipCode, userType, ...filterStatus(status) },
{ email: { contains: query }, zipCode, userType, ...filterStatus(status) },
{ telephoneNo: { contains: query }, zipCode, userType, ...filterStatus(status) },
],
AND: {
userRole: { not: "system" },
},
} satisfies Prisma.UserWhereInput;
const [result, total] = await prisma.$transaction([
@ -173,6 +229,8 @@ export class UserController extends Controller {
district: true,
subDistrict: true,
branch: { include: { branch: includeBranch } },
createdBy: true,
updatedBy: true,
},
where,
take: pageSize,
@ -207,6 +265,8 @@ export class UserController extends Controller {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
where: { id: userId },
});
@ -223,40 +283,59 @@ export class UserController extends Controller {
}
@Post()
@Security("keycloak")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin"])
async createUser(@Request() req: RequestWithUser, @Body() body: UserCreate) {
if (body.provinceId || body.districtId || body.subDistrictId) {
const [province, district, subDistrict] = 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 } }),
]);
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",
);
}
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 { provinceId, districtId, subDistrictId, username, ...rest } = body;
const { branchId, provinceId, districtId, subDistrictId, username, ...rest } = body;
let list = await getRoles();
let list = await listRole();
if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server.");
if (Array.isArray(list)) {
@ -287,7 +366,13 @@ export class UserController extends Controller {
}
const record = await prisma.user.create({
include: { province: true, district: true, subDistrict: true },
include: {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
data: {
id: userId,
...rest,
@ -297,11 +382,31 @@ export class UserController extends Controller {
province: { connect: provinceId ? { id: provinceId } : undefined },
district: { connect: districtId ? { id: districtId } : undefined },
subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined },
createdBy: req.user.name,
updatedBy: req.user.name,
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, {
@ -319,43 +424,72 @@ export class UserController extends Controller {
}
@Put("{userId}")
@Security("keycloak")
@Security("keycloak", ["system", "head_of_admin", "admin", "branch_admin", "branch_manager"])
async editUser(
@Request() req: RequestWithUser,
@Body() body: UserUpdate,
@Path() userId: string,
) {
if (body.subDistrictId || body.districtId || body.provinceId) {
const [province, district, subDistrict] = 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 } }),
]);
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",
);
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 getRoles();
let list = await listRole();
if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server.");
if (Array.isArray(list)) {
@ -394,38 +528,20 @@ export class UserController extends Controller {
await editUser(userId, { username: body.username, enabled: body.status !== "INACTIVE" });
}
const { provinceId, districtId, subDistrictId, ...rest } = body;
const user = await prisma.user.findFirst({
where: { id: userId },
});
if (!user) {
throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "branchNotFound");
}
const lastUserOfType =
body.userType &&
body.userType !== user.userType &&
user.code &&
(await prisma.user.findFirst({
orderBy: { createdAt: "desc" },
where: {
userType: body.userType,
code: { startsWith: `${user.code?.slice(0, 3)}` },
},
}));
const { provinceId, districtId, subDistrictId, branchId, ...rest } = body;
const record = await prisma.user.update({
include: { province: true, district: true, subDistrict: true },
include: {
province: true,
district: true,
subDistrict: true,
createdBy: true,
updatedBy: true,
},
data: {
...rest,
statusOrder: +(rest.status === "INACTIVE"),
userRole,
code:
(lastUserOfType &&
`${user.code?.slice(0, 3)}${body.userType !== "USER" ? body.userType?.charAt(0) : ""}${(+(lastUserOfType?.code?.slice(-4) || 0) + 1).toString().padStart(4, "0")}`) ||
undefined,
province: {
connect: provinceId ? { id: provinceId } : undefined,
disconnect: provinceId === null || undefined,
@ -438,11 +554,42 @@ export class UserController extends Controller {
connect: subDistrictId ? { id: subDistrictId } : undefined,
disconnect: subDistrictId === null || undefined,
},
updatedBy: req.user.name,
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 Object.assign(record, {
profileImageUrl: await minio.presignedGetObject(
MINIO_BUCKET,
@ -458,17 +605,35 @@ export class UserController extends Controller {
}
@Delete("{userId}")
@Security("keycloak")
async deleteUser(@Path() userId: string) {
@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");
}
@ -502,10 +667,55 @@ export class UserController extends Controller {
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) {
@ -519,11 +729,6 @@ export class UserAttachmentController extends Controller {
@Get()
async listAttachment(@Path() userId: string) {
const record = await prisma.user.findFirst({
include: {
province: true,
district: true,
subDistrict: true,
},
where: { id: userId },
});
@ -552,11 +757,6 @@ export class UserAttachmentController extends Controller {
@Post()
async addAttachment(@Path() userId: string, @Body() payload: { file: string[] }) {
const record = await prisma.user.findFirst({
include: {
province: true,
district: true,
subDistrict: true,
},
where: { id: userId },
});

View file

@ -63,6 +63,8 @@ export class WorkController extends Controller {
order: "asc",
},
},
createdBy: true,
updatedBy: true,
},
orderBy: { createdAt: "asc" },
where,
@ -106,6 +108,10 @@ export class WorkController extends Controller {
const [result, total] = await prisma.$transaction([
prisma.product.findMany({
include: {
createdBy: true,
updatedBy: true,
},
where,
take: pageSize,
skip: (page - 1) * pageSize,
@ -129,6 +135,8 @@ export class WorkController extends Controller {
product: true,
},
},
createdBy: true,
updatedBy: true,
},
where: {
productOnWork: {
@ -175,6 +183,8 @@ export class WorkController extends Controller {
order: "asc",
},
},
createdBy: true,
updatedBy: true,
},
data: {
...payload,
@ -183,13 +193,13 @@ export class WorkController extends Controller {
data: productId.map((v, i) => ({
order: i + 1,
productId: v,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
})),
},
},
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
});
@ -261,6 +271,8 @@ export class WorkController extends Controller {
order: "asc",
},
},
createdBy: true,
updatedBy: true,
},
where: { id: workId },
data: {
@ -281,13 +293,13 @@ export class WorkController extends Controller {
create: {
order: i + 1,
productId: v,
createdBy: req.user.name,
updatedBy: req.user.name,
createdByUserId: req.user.sub,
updatedByUserId: req.user.sub,
},
})),
}
: undefined,
updatedBy: req.user.name,
updatedByUserId: req.user.sub,
},
});
@ -296,7 +308,10 @@ export class WorkController extends Controller {
@Delete("{workId}")
async deleteWork(@Path() workId: string) {
const record = await prisma.work.findFirst({ where: { id: workId } });
const record = await prisma.work.findFirst({
include: { createdBy: true, updatedBy: true },
where: { id: workId },
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "Work cannot be found.", "workNotFound");

View file

@ -8,6 +8,6 @@ export type RequestWithUser = Request & {
familiy_name: string;
preferred_username: string;
email: string;
role: string[];
roles: string[];
};
};

View file

@ -19,10 +19,14 @@ const jwtDecode = createDecoder();
export async function keycloakAuth(request: Express.Request, roles?: string[]) {
const token = request.headers["authorization"]?.includes("Bearer ")
? request.headers["authorization"].split(" ")[1]
: request.headers["authorization"];
: "";
if (!token) {
throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่พบข้อมูลสำหรับยืนยันตัวตน");
throw new HttpError(
HttpStatus.UNAUTHORIZED,
"authorization data not found.",
"authDataNotFound",
);
}
let payload: Record<string, any> = {};
@ -47,7 +51,11 @@ export async function keycloakAuth(request: Express.Request, roles?: string[]) {
if (Array.isArray(payload.roles) && Array.isArray(roles) && roles.length > 0) {
if (!roles.some((a: string) => payload.roles.includes(a))) {
throw new HttpError(HttpStatus.FORBIDDEN, "คุณไม่มีสิทธิในการเข้าถึงข้อมูลดังกล่าว");
throw new HttpError(
HttpStatus.FORBIDDEN,
"You do not have permission to access this resource.",
"noPermission",
);
}
}
@ -57,7 +65,7 @@ export async function keycloakAuth(request: Express.Request, roles?: string[]) {
async function verifyOffline(token: string) {
const payload = await jwtVerify(token).catch((_) => null);
if (!payload) {
throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่สามารถยืนยันตัวตนได้");
throw new HttpError(HttpStatus.UNAUTHORIZED, "Unauthorized.", "authFailed");
}
return payload;
}
@ -70,9 +78,14 @@ async function verifyOnline(token: string) {
},
).catch((e) => console.error(e));
if (!res) throw new Error("ไม่สามารถเข้าถึงระบบยืนยันตัวตน");
if (!res)
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
"Error authentication service.",
"authFailedFatal",
);
if (!res.ok) {
throw new HttpError(HttpStatus.UNAUTHORIZED, "ไม่สามารถยืนยันตัวตนได้");
throw new HttpError(HttpStatus.UNAUTHORIZED, "Unauthorized.", "authFailed");
}
return await jwtDecode(token);

View file

@ -11,8 +11,11 @@ export async function expressAuthentication(
switch (securityName) {
case "keycloak":
const authData = await keycloakAuth(request, scopes);
if (!request.app.locals.logData) request.app.locals.logData = {};
request.app.locals.logData.sessionId = authData.session_state;
request.app.locals.logData.user = authData.preffered_username;
request.app.locals.logData.user = authData.preferred_username;
request.app.locals.logData.userName = authData.name;
request.app.locals.logData.userId = authData.sub;
return authData;
default:
throw new HttpError(

View file

@ -1,75 +0,0 @@
import { NextFunction, Request, Response } from "express";
import elasticsearch from "../services/elasticsearch";
import { randomUUID } from "crypto";
if (!process.env.ELASTICSEARCH_INDEX) {
throw new Error("Require ELASTICSEARCH_INDEX to store log.");
}
const ELASTICSEARCH_INDEX = process.env.ELASTICSEARCH_INDEX;
const LOG_LEVEL_MAP: Record<string, number> = {
debug: 4,
info: 3,
warning: 2,
error: 1,
none: 0,
};
async function logMiddleware(req: Request, res: Response, next: NextFunction) {
if (!req.url.startsWith("/api/")) return next();
let data: any;
const originalJson = res.json;
res.json = function (v: any) {
data = v;
return originalJson.call(this, v);
};
const timestamp = new Date().toString();
const start = performance.now();
req.app.locals.logData = {};
res.on("finish", () => {
if (!req.url.startsWith("/api/")) return;
const level = LOG_LEVEL_MAP[process.env.LOG_LEVEL ?? "info"] || 1;
if (level === 1 && res.statusCode < 500) return;
if (level === 2 && res.statusCode < 400) return;
if (level === 3 && res.statusCode < 200) return;
const obj = {
logType: res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warning" : "info",
systemName: "JWS-SOS",
startTimeStamp: timestamp,
endTimeStamp: new Date().toString(),
processTime: performance.now() - start,
host: req.hostname,
sessionId: req.headers["x-session-id"],
rtId: req.headers["x-rtid"],
tId: randomUUID(),
method: req.method,
endpoint: req.url,
responseCode: res.statusCode,
responseDescription: data?.code,
input: (level === 4 && JSON.stringify(req.body, null, 2)) || undefined,
output: (level === 4 && JSON.stringify(data, null, 2)) || undefined,
...req.app.locals.logData,
};
console.log(obj);
elasticsearch.index({
index: ELASTICSEARCH_INDEX,
document: obj,
});
});
return next();
}
export default logMiddleware;

40
src/middlewares/logger.ts Normal file
View file

@ -0,0 +1,40 @@
import winston from "winston";
import { ElasticsearchTransport } from "winston-elasticsearch";
import elasticsearch from "../services/elasticsearch";
const logger = winston.createLogger({
levels: winston.config.syslog.levels,
defaultMeta: { serviceName: "jws-sos" },
transports: [
new ElasticsearchTransport({
level: "info",
index: "app-log-test-winston-index",
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
client: elasticsearch,
transformer: (payload) => {
const { logData: additional, ...rest } = payload.meta;
return {
level: payload.level,
...rest,
...additional,
requestBody:
process.env.LOG_LEVEL === "debug" ? JSON.stringify(rest.requestBody) : undefined,
responseBody:
process.env.LOG_LEVEL === "debug" ? JSON.stringify(rest.responseBody) : undefined,
};
},
}),
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.printf(
({ level, timestamp, logData, responseBody, requestBody, ...payload }) =>
`${level} ${timestamp} ${JSON.stringify(Object.assign(payload, logData), null, 4)}`,
),
),
}),
],
});
export default logger;

71
src/middlewares/morgan.ts Normal file
View file

@ -0,0 +1,71 @@
import express from "express";
import morgan from "morgan";
import logger from "./logger";
import { randomUUID } from "crypto";
const LOG_LEVEL_MAP: Record<string, number> = {
debug: 4,
info: 3,
warning: 2,
error: 1,
none: 0,
};
// log the HTTP method, request URL, response status, and response time.
const logFormat = `{
"requestMethod": ":method",
"requestUrl": ":url",
"responseStatus": ":status",
"responseTime": ":response-time ms",
"transactionId": ":transaction-id",
"refTransactionId": ":ref-transaction-id",
"sessionId": ":session-id",
"requestBody": :request-body,
"responseBody": :response-body,
"logData": :log-data
}`;
function logMessageHandler(message: string) {
const data = JSON.parse(message.trim());
if (!(data.requestUrl as string).startsWith("/api/")) return;
const level = LOG_LEVEL_MAP[process.env.LOG_LEVEL ?? "info"] || 1;
const status = +data.responseStatus;
if (level === 1 && status < 500) return;
if (level === 2 && status < 400) return;
if (level === 3 && status < 200) return;
if (status >= 500) return logger.error("HTTP request received", data);
if (status >= 400) return logger.warning("HTTP request received", data);
return logger.info("HTTP request received", data);
}
morgan.token("log-data", (req: express.Request) => {
return JSON.stringify(req.app.locals.logData || {});
});
morgan.token("request-body", (req: express.Request) => {
return JSON.stringify(req.body);
});
morgan.token("response-body", (req: express.Request) => {
return JSON.stringify(req.app.locals.response || {});
});
morgan.token("identity-field", (req: express.Request) => {
return req.app.locals.identityField;
});
morgan.token("session-id", (req: express.Request) => {
return req.headers["x-session-id"] as string | undefined;
});
morgan.token("ref-transaction-id", (req: express.Request) => {
return req.headers["x-rtid"] as string | undefined;
});
morgan.token("transaction-id", () => {
return randomUUID();
});
const loggingMiddleware = morgan(logFormat, {
stream: { write: logMessageHandler },
});
export default loggingMiddleware;

View file

@ -8,10 +8,10 @@ export function role(
errorMessage: string = "You do not have permission to access this resource.",
) {
return (req: RequestWithUser, _res: Response, next: NextFunction) => {
if (!Array.isArray(role) && !req.user.role.includes(role) && !req.user.role.includes("*")) {
if (!Array.isArray(role) && !req.user.roles.includes(role) && !req.user.roles.includes("*")) {
throw new HttpError(HttpStatus.FORBIDDEN, errorMessage, "noPermissionToAccess");
}
if (role !== "*" && !req.user.role.some((v) => role.includes(v))) {
if (role !== "*" && !req.user.roles.some((v) => role.includes(v))) {
throw new HttpError(HttpStatus.FORBIDDEN, errorMessage, "noPermissionToAccess");
}
return next();

View file

@ -59,6 +59,61 @@ export async function getToken() {
return token;
}
/**
* Get keycloak user list
*
* @returns user list if success, false otherwise.
*/
export async function listUser(search = "", page = 1, pageSize = 30) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALM}/users?first=${(page - 1) * pageSize}&max=${pageSize}`.concat(
!!search ? `&search=${search}` : "",
),
{
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return;
if (!res.ok) {
return console.error("Keycloak Error Response: ", await res.json());
}
return ((await res.json()) as any[]).map((v: Record<string, string>) => ({
id: v.id,
username: v.username,
firstName: v.firstName,
lastName: v.lastName,
email: v.email,
attributes: v.attributes,
}));
}
/**
* Count user in the system. Can be use for pagination purpose.
*
* @returns numer of user on success.
*/
export async function countUser(search = "") {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALM}/users/count`.concat(!!search ? `?search=${search}` : ""),
{
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return;
if (!res.ok) {
return console.error("Keycloak Error Response: ", await res.json());
}
return (await res.json()) as number;
}
/**
* Create keycloak user by given username and password with roles
*
@ -151,36 +206,42 @@ export async function deleteUser(userId: string) {
/**
* Get roles list or specific role data
*
* Client must have permission to get realms roles
*
* @returns role's info (array if not specify name) if success, null if not found, false otherwise.
*/
export async function getRoles(name?: string) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALM}/roles`.concat((name && `/${name}`) || ""),
{
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
export async function listRole() {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/roles`, {
headers: {
authorization: `Bearer ${await getToken()}`,
},
},
).catch((e) => console.log(e));
}).catch((e) => console.log(e));
if (!res) return false;
if (!res) return;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
return console.error("Keycloak Error Response: ", await res.json());
}
if (res.status === 404) {
return null;
}
const data = await res.json();
const data = (await res.json()) as any[];
if (Array.isArray(data)) {
return data.map((v: Record<string, string>) => ({ id: v.id, name: v.name }));
return data.map((v: Record<string, string>) => ({ id: v.id, name: v.name }));
}
export async function getRoleByName(name: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/roles`.concat(`/${name}`), {
headers: {
authorization: `Bearer ${await getToken()}`,
},
}).catch((e) => console.log(e));
if (!res) return;
if (!res.ok && res.status !== 404) {
return console.error("Keycloak Error Response: ", await res.json());
}
if (res.status === 404) return null;
const data = (await res.json()) as any;
return {
id: data.id,
@ -285,7 +346,7 @@ export async function removeUserRoles(userId: string, roles: { id: string; name:
export default {
createUser,
getRoles,
listRole,
addUserRoles,
removeUserRoles,
};

View file

@ -37,13 +37,15 @@ export async function listObjectVersion(bucket: string, obj: string) {
export async function deleteObjectAllVersion(bucket: string, obj: string) {
const item = await listObjectVersion(bucket, obj);
return await new Promise((resolve, reject) => {
minio.removeObjects(
bucket,
// @ts-ignore
item.map(({ name, versionId }) => ({ name, versionId })), // type error (ts not support) - expected "string[]"
(e) => (e && reject(e)) || resolve(true),
);
return await new Promise(async (resolve, reject) => {
await minio
.removeObjects(
bucket,
item.map(({ name, versionId }) => ({ name, versionId })),
)
.catch((e) => reject(e));
resolve(true);
});
}

View file

@ -7,10 +7,10 @@
"specVersion": 3,
"securityDefinitions": {
"keycloak": {
"type": "apiKey",
"type": "http",
"name": "Authorization",
"description": "Keycloak Bearer Token",
"in": "header"
"scheme": "bearer"
}
},
"spec": {
@ -33,7 +33,8 @@
{ "name": "Product Type" },
{ "name": "Product" },
{ "name": "Work" },
{ "name": "Service" }
{ "name": "Service" },
{ "name": "Quotation" }
]
}
},