diff --git a/.env.example b/.env.example index 9445fac..511dbe2 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,15 @@ KC_SERVICE_ACCOUNT_SECRET= APP_HOST=0.0.0.0 APP_PORT=3000 +MINIO_HOST=192.168.1.20 +MINIO_PORT=9000 +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_BUCKET=jws-dev + +ELASTICSEARCH_PROTOCOL=http +ELASTICSEARCH_HOST=192.168.1.20 +ELASTICSEARCH_PORT=9200 +ELASTICSEARCH_INDEX=jws-log-index + DATABASE_URL=postgresql://postgres:1234@192.168.1.20:5432/dev_1?schema=public diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d518c84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: release-test +run-name: release-test ${{ github.actor }} +on: + push: + tags: + - "version-[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: +env: + REGISTRY: docker.frappet.com + IMAGE_NAME: jws/jws-backend + DEPLOY_HOST: 49.0.91.80 + COMPOSE_PATH: /home/frappet/docker/jws +jobs: + # act workflow_dispatch -W .github/workflows/release.yaml --input IMAGE_VER=test-v1 -s DOCKER_USER=sorawit -s DOCKER_PASS=P@ssword -s SSH_PASSWORD=P@ssw0rd + release-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + # skip Set up QEMU because it fail on act and container + # Gen Version try to get version from tag or inut + - name: Set output tags + id: vars + run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + - name: Gen Version + id: gen_ver + run: | + if [[ $GITHUB_REF == 'refs/tags/'* ]]; then + IMAGE_VER=${{ steps.vars.outputs.tag }} + else + IMAGE_VER=${{ github.event.inputs.IMAGE_VER }} + fi + if [[ $IMAGE_VER == '' ]]; then + IMAGE_VER='test-vBeta' + fi + echo '::set-output name=image_ver::'$IMAGE_VER + - name: Check Version + run: | + echo $GITHUB_REF + echo ${{ steps.gen_ver.outputs.image_ver }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login in to registry + uses: docker/login-action@v2 + with: + registry: ${{env.REGISTRY}} + username: ${{secrets.DOCKER_USER}} + password: ${{secrets.DOCKER_PASS}} + - name: Build and push docker image + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{ steps.gen_ver.outputs.image_ver }},${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest + + - uses: snow-actions/line-notify@v1.1.0 + if: success() + with: + access_token: ${{ secrets.TOKEN_LINE }} + message: | + -Success✅✅✅ + Image: ${{env.IMAGE_NAME}} + Version: ${{ steps.gen_ver.outputs.IMAGE_VER }} + By: ${{ github.actor }} + - uses: snow-actions/line-notify@v1.1.0 + if: failure() + with: + access_token: ${{ secrets.TOKEN_LINE }} + message: | + -Failure❌❌❌ + Image: ${{env.IMAGE_NAME}} + Version: ${{ steps.gen_ver.outputs.IMAGE_VER }} + By: ${{ github.actor }} diff --git a/Dockerfile b/Dockerfile index b0d0c92..c8d8da5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable +RUN apt-get update && apt-get install -y openssl +RUN pnpm i -g prisma WORKDIR /app @@ -11,6 +13,7 @@ COPY . . FROM base AS deps RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile +RUN pnpm prisma generate FROM base AS build RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile @@ -18,9 +21,13 @@ RUN pnpm prisma generate RUN pnpm run build FROM base as prod + ENV NODE_ENV="production" + COPY --from=deps /app/node_modules /app/node_modules COPY --from=build /app/dist /app/dist COPY --from=base /app/static /app/static -CMD ["pnpm", "run", "start"] +RUN chmod u+x ./entrypoint.sh + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..61a8b42 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,25 @@ +version: "3.8" + +services: + app: + build: . + restart: unless-stopped + ports: + - 3000:3000 + environment: + - KC_URL=http://192.168.1.20:8080 + - KC_REALM=dev + - KC_SERVICE_ACCOUNT_CLIENT_ID= + - KC_SERVICE_ACCOUNT_SECRET= + - APP_HOST=0.0.0.0 + - APP_PORT=3000 + - MINIO_HOST=192.168.1.20 + - MINIO_PORT=9000 + - MINIO_ACCESS_KEY= + - MINIO_SECRET_KEY= + - MINIO_BUCKET=jws-dev + - ELASTICSEARCH_PROTOCOL=http + - ELASTICSEARCH_HOST=192.168.1.20 + - ELASTICSEARCH_PORT=9200 + - ELASTICSEARCH_INDEX=jws-log-index + - DATABASE_URL=postgresql://postgres:1234@192.168.1.20:5432/dev_1?schema=public diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..4d9a5e6 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +pnpm prisma migrate deploy +pnpm run start diff --git a/package.json b/package.json index 96f769a..b2e63ea 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "template", + "name": "jws-backend", "version": "1.0.0", "description": "", "main": "./dist/app.js", @@ -24,12 +24,13 @@ "@types/swagger-ui-express": "^4.1.6", "nodemon": "^3.1.0", "prettier": "^3.2.5", - "prisma": "^5.11.0", + "prisma": "^5.12.1", "ts-node": "^10.9.2", "typescript": "^5.4.3" }, "dependencies": { - "@prisma/client": "5.11.0", + "@elastic/elasticsearch": "^8.13.0", + "@prisma/client": "5.12.1", "@tsoa/runtime": "^6.2.0", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3577b65..244f837 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,12 @@ settings: excludeLinksFromLockfile: false dependencies: + '@elastic/elasticsearch': + specifier: ^8.13.0 + version: 8.13.0 '@prisma/client': - specifier: 5.11.0 - version: 5.11.0(prisma@5.11.0) + specifier: 5.12.1 + version: 5.12.1(prisma@5.12.1) '@tsoa/runtime': specifier: ^6.2.0 version: 6.2.0 @@ -56,8 +59,8 @@ devDependencies: specifier: ^3.2.5 version: 3.2.5 prisma: - specifier: ^5.11.0 - version: 5.11.0 + specifier: ^5.12.1 + version: 5.12.1 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.12.2)(typescript@5.4.3) @@ -74,6 +77,30 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: true + /@elastic/elasticsearch@8.13.0: + resolution: {integrity: sha512-OAYgzqArPqgDaIJ1yT0RX31YCgr1lleo53zL+36i23PFjHu08CA6Uq+BmBzEV05yEidl+ILPdeSfF3G8hPG/JQ==} + engines: {node: '>=18'} + dependencies: + '@elastic/transport': 8.5.0 + tslib: 2.6.2 + transitivePeerDependencies: + - supports-color + dev: false + + /@elastic/transport@8.5.0: + resolution: {integrity: sha512-T+zSUHXBfrqlj/E9pJiaEgKoTdGykBCohzNBt6omDfI6EQtaNT240oMO03oXo35T8rwrCVonSMSoedbmToncVA==} + engines: {node: '>=18'} + dependencies: + debug: 4.3.4(supports-color@5.5.0) + hpagent: 1.2.0 + ms: 2.1.3 + secure-json-parse: 2.7.0 + tslib: 2.6.2 + undici: 6.11.1 + transitivePeerDependencies: + - supports-color + dev: false + /@hapi/accept@6.0.3: resolution: {integrity: sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==} dependencies: @@ -339,8 +366,8 @@ packages: dev: false optional: true - /@prisma/client@5.11.0(prisma@5.11.0): - resolution: {integrity: sha512-SWshvS5FDXvgJKM/a0y9nDC1rqd7KG0Q6ZVzd+U7ZXK5soe73DJxJJgbNBt2GNXOa+ysWB4suTpdK5zfFPhwiw==} + /@prisma/client@5.12.1(prisma@5.12.1): + resolution: {integrity: sha512-6/JnizEdlSBxDIdiLbrBdMW5NqDxOmhXAJaNXiPpgzAPr/nLZResT6MMpbOHLo5yAbQ1Vv5UU8PTPRzb0WIxdA==} engines: {node: '>=16.13'} requiresBuild: true peerDependencies: @@ -349,35 +376,35 @@ packages: prisma: optional: true dependencies: - prisma: 5.11.0 + prisma: 5.12.1 dev: false - /@prisma/debug@5.11.0: - resolution: {integrity: sha512-N6yYr3AbQqaiUg+OgjkdPp3KPW1vMTAgtKX6+BiB/qB2i1TjLYCrweKcUjzOoRM5BriA4idrkTej9A9QqTfl3A==} + /@prisma/debug@5.12.1: + resolution: {integrity: sha512-kd/wNsR0klrv79o1ITsbWxYyh4QWuBidvxsXSParPsYSu0ircUmNk3q4ojsgNc3/81b0ozg76iastOG43tbf8A==} - /@prisma/engines-version@5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102: - resolution: {integrity: sha512-WXCuyoymvrS4zLz4wQagSsc3/nE6CHy8znyiMv8RKazKymOMd5o9FP5RGwGHAtgoxd+aB/BWqxuP/Ckfu7/3MA==} + /@prisma/engines-version@5.12.0-21.473ed3124229e22d881cb7addf559799debae1ab: + resolution: {integrity: sha512-6yvO8s80Tym61aB4QNtYZfWVmE3pwqe807jEtzm8C5VDe7nw8O1FGX3TXUaXmWV0fQTIAfRbeL2Gwrndabp/0g==} - /@prisma/engines@5.11.0: - resolution: {integrity: sha512-gbrpQoBTYWXDRqD+iTYMirDlF9MMlQdxskQXbhARhG6A/uFQjB7DZMYocMQLoiZXO/IskfDOZpPoZE8TBQKtEw==} + /@prisma/engines@5.12.1: + resolution: {integrity: sha512-HQDdglLw2bZR/TXD2Y+YfDMvi5Q8H+acbswqOsWyq9pPjBLYJ6gzM+ptlTU/AV6tl0XSZLU1/7F4qaWa8bqpJA==} requiresBuild: true dependencies: - '@prisma/debug': 5.11.0 - '@prisma/engines-version': 5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102 - '@prisma/fetch-engine': 5.11.0 - '@prisma/get-platform': 5.11.0 + '@prisma/debug': 5.12.1 + '@prisma/engines-version': 5.12.0-21.473ed3124229e22d881cb7addf559799debae1ab + '@prisma/fetch-engine': 5.12.1 + '@prisma/get-platform': 5.12.1 - /@prisma/fetch-engine@5.11.0: - resolution: {integrity: sha512-994viazmHTJ1ymzvWugXod7dZ42T2ROeFuH6zHPcUfp/69+6cl5r9u3NFb6bW8lLdNjwLYEVPeu3hWzxpZeC0w==} + /@prisma/fetch-engine@5.12.1: + resolution: {integrity: sha512-qSs3KcX1HKcea1A+hlJVK/ljj0PNIUHDxAayGMvgJBqmaN32P9tCidlKz1EGv6WoRFICYnk3Dd/YFLBwnFIozA==} dependencies: - '@prisma/debug': 5.11.0 - '@prisma/engines-version': 5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102 - '@prisma/get-platform': 5.11.0 + '@prisma/debug': 5.12.1 + '@prisma/engines-version': 5.12.0-21.473ed3124229e22d881cb7addf559799debae1ab + '@prisma/get-platform': 5.12.1 - /@prisma/get-platform@5.11.0: - resolution: {integrity: sha512-rxtHpMLxNTHxqWuGOLzR2QOyQi79rK1u1XYAVLZxDGTLz/A+uoDnjz9veBFlicrpWjwuieM4N6jcnjj/DDoidw==} + /@prisma/get-platform@5.12.1: + resolution: {integrity: sha512-pgIR+pSvhYHiUcqXVEZS31NrFOTENC9yFUdEAcx7cdQBoZPmHVjtjN4Ss6NzVDMYPrKJJ51U14EhEoeuBlMioQ==} dependencies: - '@prisma/debug': 5.11.0 + '@prisma/debug': 5.12.1 /@tsconfig/node10@1.0.11: resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -892,7 +919,6 @@ packages: dependencies: ms: 2.1.2 supports-color: 5.5.0 - dev: true /decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} @@ -1322,7 +1348,6 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - dev: true /has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -1354,6 +1379,11 @@ packages: function-bind: 1.1.2 dev: false + /hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + dev: false + /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -1715,7 +1745,6 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1832,13 +1861,13 @@ packages: hasBin: true dev: true - /prisma@5.11.0: - resolution: {integrity: sha512-KCLiug2cs0Je7kGkQBN9jDWoZ90ogE/kvZTUTgz2h94FEo8pczCkPH7fPNXkD1sGU7Yh65risGGD1HQ5DF3r3g==} + /prisma@5.12.1: + resolution: {integrity: sha512-SkMnb6wyIxTv9ACqiHBI2u9gD6y98qXRoCoLEnZsF6yee5Qg828G+ARrESN+lQHdw4maSZFFSBPPDpvSiVTo0Q==} engines: {node: '>=16.13'} hasBin: true requiresBuild: true dependencies: - '@prisma/engines': 5.11.0 + '@prisma/engines': 5.12.1 /promise.any@2.0.6: resolution: {integrity: sha512-Ew/MrPtTjiHnnki0AA2hS2o65JaZ5n+5pp08JSyWWUdeOGF4F41P+Dn+rdqnaOV/FTxhR6eBDX412luwn3th9g==} @@ -1963,6 +1992,10 @@ packages: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} dev: false + /secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + dev: false + /semver@7.6.0: resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} engines: {node: '>=10'} @@ -2165,7 +2198,6 @@ packages: engines: {node: '>=4'} dependencies: has-flag: 3.0.0 - dev: true /swagger-ui-dist@5.13.0: resolution: {integrity: sha512-uaWhh6j18IIs5tOX0arvIBnVINAzpTXaQXkr7qAk8zoupegJVg0UU/5+S/FgsgVCnzVsJ9d7QLjIxkswEeTg0Q==} @@ -2242,6 +2274,10 @@ packages: yn: 3.1.1 dev: true + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + dev: false + /tsoa@6.2.0: resolution: {integrity: sha512-EX/RyoU+4hD1rLM5NjYG+I7lEhqx1yuLgcHs/gyWQpkX/RL9cVR9hFA9LKQrK6PE+WTg1SEahn1MK3l/+6pVKw==} engines: {node: '>=18.0.0', yarn: '>=1.9.4'} @@ -2334,6 +2370,11 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /undici@6.11.1: + resolution: {integrity: sha512-KyhzaLJnV1qa3BSHdj4AZ2ndqI0QWPxYzaIOio0WzcEJB9gvuysprJSLtpvc2D9mhR9jPDUk7xlJlZbH2KR5iw==} + engines: {node: '>=18.0'} + dev: false + /universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} diff --git a/prisma/migrations/20240409061231_init/migration.sql b/prisma/migrations/20240409061231_init/migration.sql new file mode 100644 index 0000000..9c0ad32 --- /dev/null +++ b/prisma/migrations/20240409061231_init/migration.sql @@ -0,0 +1,458 @@ +-- CreateEnum +CREATE TYPE "Status" AS ENUM ('CREATED', 'ACTIVE', 'INACTIVE'); + +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('USER', 'MESSENGER', 'DELEGATE', 'AGENCY'); + +-- CreateTable +CREATE TABLE "Province" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEN" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Province_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "District" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEN" TEXT NOT NULL, + "provinceId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "District_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SubDistrict" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEN" TEXT NOT NULL, + "zipCode" TEXT NOT NULL, + "districtId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SubDistrict_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Branch" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "taxNo" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEN" TEXT NOT NULL, + "address" TEXT NOT NULL, + "addressEN" TEXT NOT NULL, + "provinceId" TEXT, + "districtId" TEXT, + "subDistrictId" TEXT, + "zipCode" TEXT NOT NULL, + "email" TEXT NOT NULL, + "telephoneNo" TEXT NOT NULL, + "latitude" TEXT NOT NULL, + "longitude" TEXT NOT NULL, + "isHeadOffice" BOOLEAN NOT NULL DEFAULT false, + "headOfficeId" TEXT, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Branch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BranchContact" ( + "id" TEXT NOT NULL, + "telephoneNo" TEXT NOT NULL, + "lineId" TEXT NOT NULL, + "branchId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BranchContact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BranchUser" ( + "id" TEXT NOT NULL, + "branchId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BranchUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "keycloakId" TEXT NOT NULL, + "code" TEXT, + "firstName" TEXT NOT NULL, + "firstNameEN" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "lastNameEN" TEXT NOT NULL, + "gender" TEXT NOT NULL, + "address" TEXT NOT NULL, + "addressEN" TEXT NOT NULL, + "provinceId" TEXT, + "districtId" TEXT, + "subDistrictId" TEXT, + "zipCode" TEXT NOT NULL, + "email" TEXT NOT NULL, + "telephoneNo" TEXT NOT NULL, + "registrationNo" TEXT, + "startDate" TIMESTAMP(3), + "retireDate" TIMESTAMP(3), + "userType" "UserType" NOT NULL, + "userRole" TEXT NOT NULL, + "discountCondition" TEXT, + "licenseNo" TEXT, + "licenseIssueDate" TIMESTAMP(3), + "licenseExpireDate" TIMESTAMP(3), + "sourceNationality" TEXT, + "importNationality" TEXT, + "trainingPlace" TEXT, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "customerType" TEXT NOT NULL, + "customerName" TEXT NOT NULL, + "customerNameEN" TEXT NOT NULL, + "imageUrl" TEXT, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CustomerBranch" ( + "id" TEXT NOT NULL, + "branchNo" TEXT NOT NULL, + "legalPersonNo" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEN" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "taxNo" TEXT NOT NULL, + "registerName" TEXT NOT NULL, + "registerDate" TIMESTAMP(3) NOT NULL, + "authorizedCapital" TEXT NOT NULL, + "address" TEXT NOT NULL, + "addressEN" TEXT NOT NULL, + "provinceId" TEXT, + "districtId" TEXT, + "subDistrictId" TEXT, + "zipCode" TEXT NOT NULL, + "email" TEXT NOT NULL, + "telephoneNo" TEXT NOT NULL, + "latitude" TEXT NOT NULL, + "longitude" TEXT NOT NULL, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CustomerBranch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Employee" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "nrcNo" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "firstNameEN" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "lastNameEN" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "gender" TEXT NOT NULL, + "nationality" TEXT NOT NULL, + "address" TEXT NOT NULL, + "addressEN" TEXT NOT NULL, + "provinceId" TEXT, + "districtId" TEXT, + "subDistrictId" TEXT, + "zipCode" TEXT NOT NULL, + "email" TEXT NOT NULL, + "telephoneNo" TEXT NOT NULL, + "arrivalBarricade" TEXT NOT NULL, + "arrivalCardNo" TEXT NOT NULL, + "customerBranchId" TEXT, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Employee_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmployeeCheckup" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "checkupResult" TEXT NOT NULL, + "checkupType" TEXT NOT NULL, + "provinceId" TEXT, + "hospitalName" TEXT NOT NULL, + "remark" TEXT NOT NULL, + "medicalBenefitScheme" TEXT NOT NULL, + "insuranceCompany" TEXT NOT NULL, + "coverageStartDate" TIMESTAMP(3) NOT NULL, + "coverageExpireDate" TIMESTAMP(3) NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmployeeCheckup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmployeeWork" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "ownerName" TEXT NOT NULL, + "positionName" TEXT NOT NULL, + "jobType" TEXT NOT NULL, + "workplace" TEXT NOT NULL, + "workPermitNo" TEXT NOT NULL, + "workPermitIssuDate" TIMESTAMP(3) NOT NULL, + "workPermitExpireDate" TIMESTAMP(3) NOT NULL, + "workEndDate" TIMESTAMP(3) NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmployeeWork_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmployeeOtherInfo" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "citizenId" TEXT NOT NULL, + "fatherFullName" TEXT NOT NULL, + "motherFullName" TEXT NOT NULL, + "birthPlace" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmployeeOtherInfo_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Service" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "detail" TEXT NOT NULL, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Service_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Work" ( + "id" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "serviceId" TEXT NOT NULL, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Work_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkProduct" ( + "id" TEXT NOT NULL, + "workId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorkProduct_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductGroup" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "detail" TEXT NOT NULL, + "remark" TEXT NOT NULL, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductType" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "detail" TEXT NOT NULL, + "remark" TEXT NOT NULL, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductType_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "detail" TEXT NOT NULL, + "process" TEXT NOT NULL, + "price" INTEGER NOT NULL, + "agentPrice" INTEGER NOT NULL, + "serviceCharge" INTEGER NOT NULL, + "imageUrl" TEXT NOT NULL, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "productTypeId" TEXT, + "productGroupId" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "District" ADD CONSTRAINT "District_provinceId_fkey" FOREIGN KEY ("provinceId") REFERENCES "Province"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubDistrict" ADD CONSTRAINT "SubDistrict_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_provinceId_fkey" FOREIGN KEY ("provinceId") REFERENCES "Province"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_subDistrictId_fkey" FOREIGN KEY ("subDistrictId") REFERENCES "SubDistrict"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_headOfficeId_fkey" FOREIGN KEY ("headOfficeId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BranchContact" ADD CONSTRAINT "BranchContact_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BranchUser" ADD CONSTRAINT "BranchUser_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BranchUser" ADD CONSTRAINT "BranchUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_provinceId_fkey" FOREIGN KEY ("provinceId") REFERENCES "Province"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_subDistrictId_fkey" FOREIGN KEY ("subDistrictId") REFERENCES "SubDistrict"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerBranch" ADD CONSTRAINT "CustomerBranch_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerBranch" ADD CONSTRAINT "CustomerBranch_provinceId_fkey" FOREIGN KEY ("provinceId") REFERENCES "Province"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerBranch" ADD CONSTRAINT "CustomerBranch_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerBranch" ADD CONSTRAINT "CustomerBranch_subDistrictId_fkey" FOREIGN KEY ("subDistrictId") REFERENCES "SubDistrict"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_provinceId_fkey" FOREIGN KEY ("provinceId") REFERENCES "Province"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_subDistrictId_fkey" FOREIGN KEY ("subDistrictId") REFERENCES "SubDistrict"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_customerBranchId_fkey" FOREIGN KEY ("customerBranchId") REFERENCES "CustomerBranch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmployeeCheckup" ADD CONSTRAINT "EmployeeCheckup_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmployeeCheckup" ADD CONSTRAINT "EmployeeCheckup_provinceId_fkey" FOREIGN KEY ("provinceId") REFERENCES "Province"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmployeeWork" ADD CONSTRAINT "EmployeeWork_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmployeeOtherInfo" ADD CONSTRAINT "EmployeeOtherInfo_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Work" ADD CONSTRAINT "Work_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkProduct" ADD CONSTRAINT "WorkProduct_workId_fkey" FOREIGN KEY ("workId") REFERENCES "Work"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_productTypeId_fkey" FOREIGN KEY ("productTypeId") REFERENCES "ProductType"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_productGroupId_fkey" FOREIGN KEY ("productGroupId") REFERENCES "ProductGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20240409065557_update_customer/migration.sql b/prisma/migrations/20240409065557_update_customer/migration.sql new file mode 100644 index 0000000..f5291b9 --- /dev/null +++ b/prisma/migrations/20240409065557_update_customer/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `imageUrl` on the `Customer` table. All the data in the column will be lost. + - Changed the type of `customerType` on the `Customer` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "CustomerType" AS ENUM ('CORP', 'PERS'); + +-- AlterTable +ALTER TABLE "Customer" DROP COLUMN "imageUrl", +DROP COLUMN "customerType", +ADD COLUMN "customerType" "CustomerType" NOT NULL; diff --git a/prisma/migrations/20240410043453_add_missing_user_field/migration.sql b/prisma/migrations/20240410043453_add_missing_user_field/migration.sql new file mode 100644 index 0000000..a32f678 --- /dev/null +++ b/prisma/migrations/20240410043453_add_missing_user_field/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "birtDate" TIMESTAMP(3), +ADD COLUMN "responsibleArea" TEXT; diff --git a/prisma/migrations/20240410053228_fix_typo/migration.sql b/prisma/migrations/20240410053228_fix_typo/migration.sql new file mode 100644 index 0000000..59c29dd --- /dev/null +++ b/prisma/migrations/20240410053228_fix_typo/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `birtDate` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "birtDate", +ADD COLUMN "birthDate" TIMESTAMP(3); diff --git a/prisma/migrations/20240417020614_remove_field/migration.sql b/prisma/migrations/20240417020614_remove_field/migration.sql new file mode 100644 index 0000000..3cb801f --- /dev/null +++ b/prisma/migrations/20240417020614_remove_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `keycloakId` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "keycloakId"; diff --git a/prisma/migrations/20240417041541_move_field/migration.sql b/prisma/migrations/20240417041541_move_field/migration.sql new file mode 100644 index 0000000..3b720dc --- /dev/null +++ b/prisma/migrations/20240417041541_move_field/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `lineId` on the `BranchContact` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Branch" ADD COLUMN "lineId" TEXT; + +-- AlterTable +ALTER TABLE "BranchContact" DROP COLUMN "lineId"; diff --git a/prisma/migrations/20240417063829_update_table/migration.sql b/prisma/migrations/20240417063829_update_table/migration.sql new file mode 100644 index 0000000..8b7bd17 --- /dev/null +++ b/prisma/migrations/20240417063829_update_table/migration.sql @@ -0,0 +1,119 @@ +/* + Warnings: + + - You are about to drop the column `telephoneNo` on the `Branch` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Branch" DROP COLUMN "telephoneNo"; + +-- CreateTable +CREATE TABLE "Menu" ( + "id" TEXT NOT NULL, + "caption" TEXT NOT NULL, + "captionEN" TEXT NOT NULL, + "menuType" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentId" TEXT, + + CONSTRAINT "Menu_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoleMenuPermission" ( + "id" TEXT NOT NULL, + "userRole" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "menuId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RoleMenuPermission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserMenuPermission" ( + "id" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "menuId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserMenuPermission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MenuComponent" ( + "id" TEXT NOT NULL, + "componentId" TEXT NOT NULL, + "componentTag" TEXT NOT NULL, + "menuId" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MenuComponent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoleMenuComponentPermission" ( + "id" TEXT NOT NULL, + "componentId" TEXT NOT NULL, + "componentTag" TEXT NOT NULL, + "menuComponentId" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RoleMenuComponentPermission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserMenuComponentPermission" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "menuComponentId" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updateBy" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserMenuComponentPermission_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Menu" ADD CONSTRAINT "Menu_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Menu"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleMenuPermission" ADD CONSTRAINT "RoleMenuPermission_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "Menu"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMenuPermission" ADD CONSTRAINT "UserMenuPermission_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "Menu"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMenuPermission" ADD CONSTRAINT "UserMenuPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MenuComponent" ADD CONSTRAINT "MenuComponent_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "Menu"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleMenuComponentPermission" ADD CONSTRAINT "RoleMenuComponentPermission_menuComponentId_fkey" FOREIGN KEY ("menuComponentId") REFERENCES "MenuComponent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMenuComponentPermission" ADD CONSTRAINT "UserMenuComponentPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMenuComponentPermission" ADD CONSTRAINT "UserMenuComponentPermission_menuComponentId_fkey" FOREIGN KEY ("menuComponentId") REFERENCES "MenuComponent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240417064127_add_missing_user_field/migration.sql b/prisma/migrations/20240417064127_add_missing_user_field/migration.sql new file mode 100644 index 0000000..b0b8442 --- /dev/null +++ b/prisma/migrations/20240417064127_add_missing_user_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Branch" ADD COLUMN "contactName" TEXT; diff --git a/prisma/migrations/20240417091605_add_missing_user_field_checkpoint/migration.sql b/prisma/migrations/20240417091605_add_missing_user_field_checkpoint/migration.sql new file mode 100644 index 0000000..30b641e --- /dev/null +++ b/prisma/migrations/20240417091605_add_missing_user_field_checkpoint/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "checkpoint" TEXT, +ADD COLUMN "checkpointEN" TEXT; diff --git a/prisma/migrations/20240417094807_add_username_field/migration.sql b/prisma/migrations/20240417094807_add_username_field/migration.sql new file mode 100644 index 0000000..2d730b3 --- /dev/null +++ b/prisma/migrations/20240417094807_add_username_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "username" TEXT NOT NULL; diff --git a/prisma/migrations/20240418042147_fix_missing_field/migration.sql b/prisma/migrations/20240418042147_fix_missing_field/migration.sql new file mode 100644 index 0000000..c22f622 --- /dev/null +++ b/prisma/migrations/20240418042147_fix_missing_field/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `componentId` on the `RoleMenuComponentPermission` table. All the data in the column will be lost. + - You are about to drop the column `componentTag` on the `RoleMenuComponentPermission` table. All the data in the column will be lost. + - Added the required column `userRole` to the `RoleMenuComponentPermission` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "RoleMenuComponentPermission" DROP COLUMN "componentId", +DROP COLUMN "componentTag", +ADD COLUMN "userRole" TEXT NOT NULL; diff --git a/prisma/migrations/20240418060611_add_hq_tel_field/migration.sql b/prisma/migrations/20240418060611_add_hq_tel_field/migration.sql new file mode 100644 index 0000000..ae17c14 --- /dev/null +++ b/prisma/migrations/20240418060611_add_hq_tel_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Branch" ADD COLUMN "telephoneHq" TEXT; diff --git a/prisma/migrations/20240418060739_remove_optional_hq_tel/migration.sql b/prisma/migrations/20240418060739_remove_optional_hq_tel/migration.sql new file mode 100644 index 0000000..922dfa6 --- /dev/null +++ b/prisma/migrations/20240418060739_remove_optional_hq_tel/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `telephoneHq` on table `Branch` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Branch" ALTER COLUMN "telephoneHq" SET NOT NULL; diff --git a/prisma/migrations/20240418061959_rename_field/migration.sql b/prisma/migrations/20240418061959_rename_field/migration.sql new file mode 100644 index 0000000..6233aa1 --- /dev/null +++ b/prisma/migrations/20240418061959_rename_field/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `telephoneHq` on the `Branch` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Branch" DROP COLUMN "telephoneHq", +ADD COLUMN "telephoneNo" TEXT; diff --git a/prisma/migrations/20240418062042_remove_optional/migration.sql b/prisma/migrations/20240418062042_remove_optional/migration.sql new file mode 100644 index 0000000..ca8ae37 --- /dev/null +++ b/prisma/migrations/20240418062042_remove_optional/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `telephoneNo` on table `Branch` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Branch" ALTER COLUMN "telephoneNo" SET NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abd8bb9..1652ee2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,9 +7,112 @@ datasource db { url = env("DATABASE_URL") } +model Menu { + id String @id @default(uuid()) + + caption String + captionEN String + menuType String + url String + + createdBy String? + createdAt DateTime @default(now()) + updateBy String? + updatedAt DateTime @updatedAt + + parent Menu? @relation(name: "MenuRelation", fields: [parentId], references: [id]) + parentId String? + + children Menu[] @relation(name: "MenuRelation") + roleMenuPermission RoleMenuPermission[] + userMenuPermission UserMenuPermission[] + userComponent MenuComponent[] +} + +model RoleMenuPermission { + id String @id @default(uuid()) + + userRole String + permission String + + menu Menu @relation(fields: [menuId], references: [id]) + menuId String + + createdBy String? + createdAt DateTime @default(now()) + updateBy String? + updatedAt DateTime @updatedAt +} + +model UserMenuPermission { + id String @id @default(uuid()) + + permission String + + menu Menu @relation(fields: [menuId], references: [id]) + menuId String + + user User @relation(fields: [userId], references: [id]) + userId String + + createdBy String? + createdAt DateTime @default(now()) + updateBy String? + updatedAt DateTime @updatedAt +} + +model MenuComponent { + id String @id @default(uuid()) + + componentId String + componentTag String + + menu Menu @relation(fields: [menuId], references: [id]) + menuId String + + createdBy String? + createdAt DateTime @default(now()) + updateBy String? + updatedAt DateTime @updatedAt + roleMenuComponentPermission RoleMenuComponentPermission[] + userMennuComponentPermission UserMenuComponentPermission[] +} + +model RoleMenuComponentPermission { + id String @id @default(uuid()) + + userRole String + permission String + + menuComponent MenuComponent @relation(fields: [menuComponentId], references: [id]) + menuComponentId String + + createdBy String? + createdAt DateTime @default(now()) + updateBy String? + updatedAt DateTime @updatedAt +} + +model UserMenuComponentPermission { + id String @id @default(uuid()) + + userId String + user User @relation(fields: [userId], references: [id]) + + menuComponent MenuComponent @relation(fields: [menuComponentId], references: [id]) + menuComponentId String + + permission String + + createdBy String? + createdAt DateTime @default(now()) + updateBy String? + updatedAt DateTime @updatedAt +} + model Province { id String @id @default(uuid()) - nameTH String + name String nameEN String createdBy String? @@ -27,7 +130,7 @@ model Province { model District { id String @id @default(uuid()) - nameTH String + name String nameEN String provinceId String @@ -47,7 +150,7 @@ model District { model SubDistrict { id String @id @default(uuid()) - nameTH String + name String nameEN String zipCode String @@ -65,14 +168,21 @@ model SubDistrict { employee Employee[] } +enum Status { + CREATED + ACTIVE + INACTIVE +} + model Branch { - id String @id @default(uuid()) - code String - taxNo String - nameTH String - nameEN String - addressTH String - addressEN String + id String @id @default(uuid()) + code String + taxNo String + name String + nameEN String + address String + addressEN String + telephoneNo String province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull) provinceId String? @@ -86,7 +196,8 @@ model Branch { zipCode String email String - telephoneNo String + contactName String? + lineId String? latitude String longitude String @@ -96,7 +207,7 @@ model Branch { headOffice Branch? @relation(name: "HeadOfficeRelation", fields: [headOfficeId], references: [id]) headOfficeId String? - status String? + status Status @default(CREATED) createdBy String? createdAt DateTime @default(now()) @@ -109,10 +220,8 @@ model Branch { } model BranchContact { - id String @id @default(uuid()) - telephoneNo String - lineId String - qrCodeImageUrl String? + id String @id @default(uuid()) + telephoneNo String branch Branch @relation(fields: [branchId], references: [id], onDelete: Cascade) branchId String @@ -138,16 +247,25 @@ model BranchUser { updatedAt DateTime @updatedAt } +enum UserType { + USER + MESSENGER + DELEGATE + AGENCY +} + model User { id String @id @default(uuid()) - code String - firstNameTH String + code String? + firstName String firstNameEN String - lastNameTH String + lastName String lastNameEN String + username String + gender String - addressTH String + address String addressEN String province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull) @@ -164,46 +282,56 @@ model User { email String telephoneNo String - registrationNo String + registrationNo String? - startDate DateTime - retireDate DateTime + startDate DateTime? + retireDate DateTime? - profileImageUrl String? + checkpoint String? + checkpointEN String? - userType String + userType UserType userRole String - discountCondition String + discountCondition String? - licenseNo String - licenseIssueDate DateTime - licenseExpireDate DateTime + licenseNo String? + licenseIssueDate DateTime? + licenseExpireDate DateTime? - sourceNationality String - importNationality String + sourceNationality String? + importNationality String? - trainingPlace String + trainingPlace String? + responsibleArea String? - status String? + birthDate DateTime? + + status Status @default(CREATED) createdBy String? createdAt DateTime @default(now()) updateBy String? updatedAt DateTime @updatedAt - branch BranchUser[] + branch BranchUser[] + userMenuPermission UserMenuPermission[] + userMenuComponentPermission UserMenuComponentPermission[] +} + +enum CustomerType { + CORP + PERS } model Customer { - id String @id @default(uuid()) + id String @id @default(uuid()) code String - customerType String - customerNameTH String + customerType CustomerType + customerName String customerNameEN String - imageUrl String? - status String? + status Status @default(CREATED) createdBy String? createdAt DateTime @default(now()) @@ -218,7 +346,7 @@ model CustomerBranch { branchNo String legalPersonNo String - nameTH String + name String nameEN String customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) @@ -229,6 +357,7 @@ model CustomerBranch { registerDate DateTime authorizedCapital String + address String addressEN String province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull) @@ -248,6 +377,8 @@ model CustomerBranch { latitude String longitude String + status Status @default(CREATED) + createdBy String? createdAt DateTime @default(now()) updateBy String? @@ -260,13 +391,17 @@ model Employee { id String @id @default(uuid()) code String - fullNameTH String - fullNameEN String + nrcNo String + firstName String + firstNameEN String + lastName String + lastNameEN String + dateOfBirth DateTime gender String nationality String - addressTH String + address String addressEN String province Province? @relation(fields: [provinceId], references: [id], onDelete: SetNull) @@ -285,12 +420,11 @@ model Employee { arrivalBarricade String arrivalCardNo String - profileImageUrl String customerBranch CustomerBranch? @relation(fields: [customerBranchId], references: [id], onDelete: SetNull) customerBranchId String? - status String? + status Status @default(CREATED) createdBy String? createdAt DateTime @default(now()) @@ -299,7 +433,7 @@ model Employee { employeeCheckup EmployeeCheckup[] employeeWork EmployeeWork[] - EmployeeOtherInfo EmployeeOtherInfo[] + employeeOtherInfo EmployeeOtherInfo[] } model EmployeeCheckup { @@ -368,11 +502,11 @@ model EmployeeOtherInfo { model Service { id String @id @default(uuid()) - code String - name String - detail String - imageUrl String - status String? + code String + name String + detail String + + status Status @default(CREATED) createdBy String? createdAt DateTime @default(now()) @@ -384,12 +518,14 @@ model Service { model Work { id String @id @default(uuid()) - order String + order Int name String service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade) serviceId String + status Status @default(CREATED) + createdBy String? createdAt DateTime @default(now()) updateBy String? @@ -416,7 +552,8 @@ model ProductGroup { name String detail String remark String - status String? + + status Status @default(CREATED) createdBy String? createdAt DateTime @default(now()) @@ -432,7 +569,8 @@ model ProductType { name String detail String remark String - status String? + + status Status @default(CREATED) createdBy String? createdAt DateTime @default(now()) @@ -453,7 +591,8 @@ model Product { agentPrice Int serviceCharge Int imageUrl String - status String? + + status Status @default(CREATED) productType ProductType? @relation(fields: [productTypeId], references: [id], onDelete: SetNull) productTypeId String? diff --git a/src/app.ts b/src/app.ts index e7b19d2..d9c0a8f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import swaggerUi from "swagger-ui-express"; import swaggerDocument from "./swagger.json"; import error from "./middlewares/error"; import { RegisterRoutes } from "./routes"; +import logMiddleware from "./middlewares/log"; const APP_HOST = process.env.APP_HOST || "0.0.0.0"; const APP_PORT = +(process.env.APP_PORT || 3000); @@ -15,11 +16,22 @@ const APP_PORT = +(process.env.APP_PORT || 3000); app.use(cors()); app.use(json()); app.use(urlencoded({ extended: true })); + + app.use(logMiddleware); + app.use("/", express.static("static")); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); RegisterRoutes(app); + app.get("*", (_, res) => + res.status(404).send({ + status: 404, + message: "Route not found.", + devMessage: "unknown_url", + }), + ); + app.use(error); app.listen(APP_PORT, APP_HOST, () => console.log(`Listening on: http://localhost:${APP_PORT}`)); diff --git a/src/controllers/address-controller.ts b/src/controllers/address-controller.ts new file mode 100644 index 0000000..c836829 --- /dev/null +++ b/src/controllers/address-controller.ts @@ -0,0 +1,56 @@ +import { Controller, Get, Path, Route, Tags } from "tsoa"; +import prisma from "../db"; + +@Route("api/address") +@Tags("Address") +export class AddressController extends Controller { + @Get("province") + async getProvince() { + return await prisma.province.findMany(); + } + + @Get("province/{provinceId}") + async getProvinceById(@Path() provinceId: string) { + return await prisma.province.findFirst({ + where: { id: provinceId }, + }); + } + + @Get("province/{provinceId}/district") + async getDistrictOfProvince(@Path() provinceId: string) { + return await prisma.district.findMany({ + where: { provinceId }, + }); + } + + @Get("district") + async getDistrict() { + return await prisma.district.findMany(); + } + + @Get("district/{districtId}") + async getDistrictOfId(@Path() districtId: string) { + return await prisma.province.findFirst({ + where: { id: districtId }, + }); + } + + @Get("district/{districtId}/sub-district") + async getSubDistrictOfDistrict(@Path() districtId: string) { + return await prisma.subDistrict.findMany({ + where: { districtId }, + }); + } + + @Get("sub-district") + async getSubDistrict() { + return await prisma.subDistrict.findMany(); + } + + @Get("sub-district/{subDistrictId}") + async getSubDistrictOfId(@Path() subDistrictId: string) { + return await prisma.subDistrict.findFirst({ + where: { id: subDistrictId }, + }); + } +} diff --git a/src/controllers/branch-contact-controller.ts b/src/controllers/branch-contact-controller.ts new file mode 100644 index 0000000..b71aea5 --- /dev/null +++ b/src/controllers/branch-contact-controller.ts @@ -0,0 +1,132 @@ +import { + Body, + Controller, + Delete, + Get, + Put, + Path, + Post, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; + +import prisma from "../db"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { RequestWithUser } from "../interfaces/user"; + +type BranchContactCreate = { + telephoneNo: string; +}; + +type BranchContactUpdate = { + telephoneNo?: string; +}; + +@Route("api/branch/{branchId}/contact") +@Tags("Branch Contact") +@Security("keycloak") +export class BranchContactController extends Controller { + @Get() + async getBranchContact( + @Path() branchId: string, + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const [result, total] = await prisma.$transaction([ + prisma.branchContact.findMany({ + orderBy: { createdAt: "asc" }, + where: { branchId }, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.branchContact.count({ where: { branchId } }), + ]); + + return { + result, + page, + pageSize, + total, + }; + } + + @Get("{contactId}") + async getBranchContactById(@Path() branchId: string, @Path() contactId: string) { + const record = await prisma.branchContact.findFirst({ where: { id: contactId, branchId } }); + + if (!record) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Branch contact cannot be found.", + "data_not_found", + ); + } + + return record; + } + + @Post() + async createBranchContact( + @Request() req: RequestWithUser, + @Path() branchId: string, + @Body() body: BranchContactCreate, + ) { + if (!(await prisma.branch.findFirst({ where: { id: branchId } }))) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Branch not found.", + "missing_or_invalid_parameter", + ); + } + const record = await prisma.branchContact.create({ + data: { ...body, branchId, createdBy: req.user.name, updateBy: req.user.name }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Put("{contactId}") + async editBranchContact( + @Request() req: RequestWithUser, + @Body() body: BranchContactUpdate, + @Path() branchId: string, + @Path() contactId: string, + ) { + if ( + !(await prisma.branchContact.findUnique({ + where: { id: contactId, branchId }, + })) + ) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Branch contact cannot be found.", + "data_not_found", + ); + } + + const record = await prisma.branchContact.update({ + data: { ...body, updateBy: req.user.name }, + where: { id: contactId, branchId }, + }); + + return record; + } + + @Delete("{contactId}") + async deleteBranchContact(@Path() branchId: string, @Path() contactId: string) { + const result = await prisma.branchContact.deleteMany({ where: { id: contactId, branchId } }); + if (result.count <= 0) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Branch contact cannot be found.", + "data_not_found", + ); + } + } +} diff --git a/src/controllers/branch-controller.ts b/src/controllers/branch-controller.ts new file mode 100644 index 0000000..9aa12a3 --- /dev/null +++ b/src/controllers/branch-controller.ts @@ -0,0 +1,447 @@ +import { Prisma, Status, UserType } from "@prisma/client"; +import { + Body, + Controller, + Delete, + Get, + Put, + Path, + Post, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; + +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"; + +if (!process.env.MINIO_BUCKET) { + throw Error("Require MinIO bucket."); +} + +const MINIO_BUCKET = process.env.MINIO_BUCKET; + +type BranchCreate = { + status?: Status; + taxNo: string; + nameEN: string; + name: string; + addressEN: string; + address: string; + zipCode: string; + email: string; + contactName?: string | null; + contact?: string | string[] | null; + telephoneNo: string; + lineId?: string | null; + longitude: string; + latitude: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; + headOfficeId?: string | null; +}; + +type BranchUpdate = { + status?: "ACTIVE" | "INACTIVE"; + taxNo?: string; + nameEN?: string; + name?: string; + addressEN?: string; + address?: string; + zipCode?: string; + email?: string; + telephoneNo: string; + contactName?: string; + contact?: string | string[] | null; + lineId?: string; + longitude?: string; + latitude?: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; + headOfficeId?: string | null; +}; + +function lineImageLoc(id: string) { + return `branch/line-qr-${id}`; +} + +function branchImageLoc(id: string) { + return `branch/branch-img-${id}`; +} + +@Route("api/branch") +@Tags("Branch") +@Security("keycloak") +export class BranchController extends Controller { + @Get("stats") + async getStats() { + const list = await prisma.branch.groupBy({ + _count: true, + by: "isHeadOffice", + }); + + return list.reduce>( + (a, c) => { + a[c.isHeadOffice ? "hq" : "br"] = c._count; + return a; + }, + { hq: 0, br: 0 }, + ); + } + + @Get("user-stats") + async getUserStat(@Query() userType?: UserType) { + const list = await prisma.branchUser.groupBy({ + _count: true, + where: { user: { userType } }, + by: "branchId", + }); + + const record = await prisma.branch.findMany({ + select: { + id: true, + nameEN: true, + name: true, + isHeadOffice: true, + }, + }); + + return record.map((a) => + Object.assign(a, { + count: list.find((b) => b.branchId === a.id)?._count ?? 0, + }), + ); + } + + @Get() + async getBranch( + @Query() zipCode?: string, + @Query() filter?: "head" | "sub", + @Query() headOfficeId?: string, + @Query() tree?: boolean, + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + AND: { + headOfficeId: headOfficeId ?? (filter === "head" || tree ? null : undefined), + NOT: { headOfficeId: filter === "sub" && !headOfficeId ? null : undefined }, + }, + OR: [ + { nameEN: { contains: query }, zipCode }, + { name: { contains: query }, zipCode }, + { email: { contains: query }, zipCode }, + { telephoneNo: { contains: query }, zipCode }, + ], + } satisfies Prisma.BranchWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.branch.findMany({ + orderBy: { createdAt: "asc" }, + include: { + province: true, + district: true, + subDistrict: true, + contact: true, + branch: tree && { + include: { + province: true, + district: true, + subDistrict: true, + }, + }, + }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.branch.count({ where }), + ]); + + return { result, page, pageSize, total }; + } + + @Get("{branchId}") + async getBranchById( + @Path() branchId: string, + @Query() includeSubBranch?: boolean, + @Query() includeContact?: boolean, + ) { + const record = await prisma.branch.findFirst({ + include: { + province: true, + district: true, + subDistrict: true, + branch: includeSubBranch && { + include: { + province: true, + district: true, + subDistrict: true, + }, + }, + contact: includeContact, + }, + where: { id: branchId }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "data_not_found"); + } + + return Object.assign(record, { + imageUrl: await minio.presignedGetObject(MINIO_BUCKET, branchImageLoc(record.id)), + qrCodeImageUrl: await minio.presignedGetObject(MINIO_BUCKET, lineImageLoc(record.id)), + }); + } + + @Post() + async createBranch(@Request() req: RequestWithUser, @Body() body: BranchCreate) { + const [province, district, subDistrict, head] = 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.findFirst({ where: { id: body.headOfficeId || 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", + ); + if (body.headOfficeId && !head) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Head branch cannot be found.", + "missing_or_invalid_parameter", + ); + + const { provinceId, districtId, subDistrictId, headOfficeId, contact, ...rest } = body; + + const year = new Date().getFullYear(); + + const last = await prisma.branch.findFirst({ + orderBy: { createdAt: "desc" }, + where: { headOfficeId: headOfficeId ?? null }, + }); + + const code = !headOfficeId + ? `HQ${year.toString().slice(2)}${+(last?.code.slice(-1) || 0) + 1}` + : `BR${head?.code.slice(2, 5)}${(+(last?.code.slice(-2) || 0) + 1).toString().padStart(2, "0")}`; + + const record = await prisma.branch.create({ + include: { + province: true, + district: true, + subDistrict: true, + }, + data: { + ...rest, + code, + 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, + updateBy: req.user.name, + }, + }); + + if (headOfficeId) { + await prisma.branch.updateMany({ + where: { id: headOfficeId, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }); + } + + this.setStatus(HttpStatus.CREATED); + + if (record && contact) { + await prisma.branchContact.createMany({ + data: + typeof contact === "string" + ? [{ telephoneNo: contact, branchId: record.id }] + : contact.map((v) => ({ telephoneNo: v, branchId: record.id })), + }); + } + + 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( + MINIO_BUCKET, + lineImageLoc(record.id), + 12 * 60 * 60, + ), + qrCodeImageUploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + lineImageLoc(record.id), + 12 * 60 * 60, + ), + }); + } + + @Put("{branchId}") + async editBranch( + @Request() req: RequestWithUser, + @Body() body: BranchUpdate, + @Path() branchId: string, + ) { + if (body.headOfficeId === branchId) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Cannot make this as head office and branch at the same time.", + "missing_or_invalid_parameter", + ); + + if (body.subDistrictId || body.districtId || body.provinceId || body.headOfficeId) { + 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.findFirst({ where: { id: body.headOfficeId || 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", + ); + if (body.headOfficeId && !branch) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Head branch cannot be found.", + "missing_or_invalid_parameter", + ); + } + + const { provinceId, districtId, subDistrictId, headOfficeId, contact, ...rest } = body; + + if (!(await prisma.branch.findUnique({ where: { id: branchId } }))) { + throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "data_not_found"); + } + + const record = await prisma.branch.update({ + include: { province: true, district: true, subDistrict: true }, + data: { + ...rest, + isHeadOffice: headOfficeId !== undefined ? headOfficeId === null : undefined, + province: { + connect: provinceId ? { id: provinceId } : undefined, + disconnect: provinceId === null || undefined, + }, + district: { + connect: districtId ? { id: districtId } : undefined, + disconnect: districtId === null || undefined, + }, + subDistrict: { + connect: subDistrictId ? { id: subDistrictId } : undefined, + disconnect: subDistrictId === null || undefined, + }, + headOffice: { + connect: headOfficeId ? { id: headOfficeId } : undefined, + disconnect: headOfficeId === null || undefined, + }, + updateBy: req.user.name, + }, + where: { id: branchId }, + }); + + if (record && contact !== undefined) { + await prisma.branchContact.deleteMany({ where: { branchId } }); + contact && + (await prisma.branchContact.createMany({ + data: + typeof contact === "string" + ? [{ telephoneNo: contact, branchId }] + : contact.map((v) => ({ telephoneNo: v, branchId })), + })); + } + + 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( + MINIO_BUCKET, + lineImageLoc(record.id), + 12 * 60 * 60, + ), + qrCodeImageUploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + lineImageLoc(record.id), + 12 * 60 * 60, + ), + }); + } + + @Delete("{branchId}") + async deleteBranch(@Path() branchId: string) { + const record = await prisma.branch.findFirst({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: branchId }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "data_not_found"); + } + + if (record.status !== Status.CREATED) { + throw new HttpError(HttpStatus.FORBIDDEN, "Branch is in used.", "data_in_used"); + } + + await minio.removeObject(MINIO_BUCKET, lineImageLoc(branchId), { + forceDelete: true, + }); + await minio.removeObject(MINIO_BUCKET, branchImageLoc(branchId), { + forceDelete: true, + }); + + return await prisma.branch.delete({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: branchId }, + }); + } +} diff --git a/src/controllers/branch-user-controller.ts b/src/controllers/branch-user-controller.ts new file mode 100644 index 0000000..d33b307 --- /dev/null +++ b/src/controllers/branch-user-controller.ts @@ -0,0 +1,290 @@ +import { Prisma, Status, UserType } from "@prisma/client"; +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; + +import prisma from "../db"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { RequestWithUser } from "../interfaces/user"; + +type BranchUserBody = { user: string[] }; + +@Route("api/branch/{branchId}/user") +@Tags("Branch User") +@Security("keycloak") +export class BranchUserController extends Controller { + @Get() + async getBranchUser( + @Path() branchId: string, + @Query() zipCode?: string, + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + OR: [ + { user: { firstName: { contains: query }, zipCode }, branchId }, + { user: { firstNameEN: { contains: query }, zipCode }, branchId }, + { user: { lastName: { contains: query }, zipCode }, branchId }, + { user: { lastNameEN: { contains: query }, zipCode }, branchId }, + { user: { email: { contains: query }, zipCode }, branchId }, + { user: { telephoneNo: { contains: query }, zipCode }, branchId }, + ], + } satisfies Prisma.BranchUserWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.branchUser.findMany({ + orderBy: { user: { createdAt: "asc" } }, + include: { + user: { + include: { + province: true, + district: true, + subDistrict: true, + }, + }, + }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.branchUser.count({ where }), + ]); + + return { result: result.map((v) => v.user), page, pageSize, total }; + } + + @Post() + async createBranchUser( + @Request() req: RequestWithUser, + @Path() branchId: string, + @Body() body: BranchUserBody, + ) { + const [branch, user] = await prisma.$transaction([ + prisma.branch.findUnique({ + where: { id: branchId }, + }), + prisma.user.findMany({ + include: { branch: true }, + where: { id: { in: body.user } }, + }), + ]); + + if (!branch) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Branch cannot be found.", + "missing_or_invalid_parameter", + ); + } + + if (user.length !== body.user.length) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "One or more user cannot be found.", + "missing_or_invalid_parameter", + ); + } + + await prisma.$transaction([ + prisma.user.updateMany({ + where: { id: { in: body.user }, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }), + prisma.branchUser.createMany({ + data: user + .filter((a) => !a.branch.some((b) => b.branchId === branchId)) + .map((v) => ({ + branchId, + userId: v.id, + createdBy: req.user.name, + updateBy: req.user.name, + })), + }), + ]); + + const group: Record = { + USER: [], + AGENCY: [], + DELEGATE: [], + MESSENGER: [], + }; + + for (const u of user) group[u.userType].push(u.id); + + for (const g of Object.values(UserType)) { + if (group[g].length === 0) continue; + + const last = await prisma.branchUser.findFirst({ + orderBy: { createdAt: "desc" }, + include: { user: true }, + where: { + branchId, + user: { + userType: g, + code: { startsWith: `${branch.code.slice(4).padEnd(3, "0")}` }, + }, + }, + }); + + const code = (idx: number) => + `${branch.code.slice(4).padEnd(3, "0")}${g !== "USER" ? g.charAt(0) : ""}${(+(last?.user.code?.slice(-4) || 0) + idx + 1).toString().padStart(4, "0")}`; + + await prisma.$transaction( + group[g].map((v, i) => + prisma.user.updateMany({ + where: { id: v, code: null }, + data: { code: code(i) }, + }), + ), + ); + } + } + + @Delete() + async deleteBranchUser(@Path() branchId: string, @Body() body: BranchUserBody) { + await prisma.$transaction( + body.user.map((v) => prisma.branchUser.deleteMany({ where: { branchId, userId: v } })), + ); + await prisma.user.updateMany({ + where: { + branch: { none: {} }, + }, + data: { code: null }, + }); + } + + @Delete("{userId}") + async deleteBranchUserById(@Path() branchId: string, @Path() userId: string) { + await prisma.branchUser.deleteMany({ + where: { branchId, userId }, + }); + await prisma.user.updateMany({ + where: { + id: userId, + branch: { none: {} }, + }, + data: { code: null }, + }); + } +} + +type UserBranchBody = { branch: string[] }; + +@Route("api/user/{userId}/branch") +@Tags("Branch User") +@Security("keycloak") +export class UserBranchController extends Controller { + @Get() + async getUserBranch( + @Path() userId: string, + @Query() zipCode?: string, + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + OR: [ + { branch: { name: { contains: query }, zipCode }, userId }, + { branch: { nameEN: { contains: query }, zipCode }, userId }, + ], + } satisfies Prisma.BranchUserWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.branchUser.findMany({ + orderBy: { branch: { createdAt: "asc" } }, + include: { + branch: { + include: { + province: true, + district: true, + subDistrict: true, + }, + }, + }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.branchUser.count({ where }), + ]); + + return { result: result.map((v) => v.branch), page, pageSize, total }; + } + + @Post() + async createUserBranch( + @Request() req: RequestWithUser, + @Path() userId: string, + @Body() body: UserBranchBody, + ) { + const branch = await prisma.branch.findMany({ + include: { user: true }, + where: { id: { in: body.branch } }, + }); + + if (branch.length !== body.branch.length) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "One or more branch cannot be found.", + "missing_or_invalid_parameter", + ); + } + + await prisma.branch.updateMany({ + where: { id: { in: body.branch }, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }); + + await prisma.branchUser.createMany({ + data: branch + .filter((a) => !a.user.some((b) => b.userId === userId)) + .map((v) => ({ + branchId: v.id, + userId, + createdBy: req.user.name, + updateBy: req.user.name, + })), + }); + + this.setStatus(HttpStatus.CREATED); + } + + @Delete() + async deleteUserBranch(@Path() userId: string, @Body() body: UserBranchBody) { + await prisma.$transaction( + body.branch.map((v) => prisma.branchUser.deleteMany({ where: { userId, branchId: v } })), + ); + await prisma.user.updateMany({ + where: { + branch: { none: {} }, + }, + data: { code: null }, + }); + } + + @Delete("{branchId}") + async deleteUserBranchById(@Path() branchId: string, @Path() userId: string) { + await prisma.branchUser.deleteMany({ + where: { branchId, userId }, + }); + await prisma.user.updateMany({ + where: { + id: userId, + branch: { none: {} }, + }, + data: { code: null }, + }); + } +} diff --git a/src/controllers/customer-branch-controller.ts b/src/controllers/customer-branch-controller.ts new file mode 100644 index 0000000..c2220cc --- /dev/null +++ b/src/controllers/customer-branch-controller.ts @@ -0,0 +1,356 @@ +import { Prisma, Status } from "@prisma/client"; +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { RequestWithUser } from "../interfaces/user"; +import prisma from "../db"; +import HttpStatus from "../interfaces/http-status"; +import HttpError from "../interfaces/http-error"; +import minio from "../services/minio"; + +if (!process.env.MINIO_BUCKET) { + throw Error("Require MinIO bucket."); +} + +const MINIO_BUCKET = process.env.MINIO_BUCKET; + +function imageLocation(id: string) { + return `employee/profile-img-${id}`; +} + +type CustomerBranchCreate = { + customerId: string; + + status?: Status; + + legalPersonNo: string; + + taxNo: string; + name: string; + nameEN: string; + addressEN: string; + address: string; + zipCode: string; + email: string; + telephoneNo: string; + longitude: string; + latitude: string; + + registerName: string; + registerDate: Date; + authorizedCapital: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; +}; + +type CustomerBranchUpdate = { + customerId?: string; + + status?: "ACTIVE" | "INACTIVE"; + + legalPersonNo?: string; + + taxNo?: string; + name?: string; + nameEN?: string; + addressEN?: string; + address?: string; + zipCode?: string; + email?: string; + telephoneNo?: string; + longitude?: string; + latitude?: string; + + registerName?: string; + registerDate?: Date; + authorizedCapital?: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; +}; + +@Route("api/customer-branch") +@Tags("Customer Branch") +@Security("keycloak") +export class CustomerBranchController extends Controller { + @Get() + async list( + @Query() zipCode?: string, + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + OR: [ + { nameEN: { contains: query }, zipCode }, + { name: { contains: query }, zipCode }, + { email: { contains: query }, zipCode }, + ], + } satisfies Prisma.BranchWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.customerBranch.findMany({ + orderBy: { createdAt: "asc" }, + include: { + province: true, + district: true, + subDistrict: true, + }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.customerBranch.count({ where }), + ]); + + return { result, page, pageSize, total }; + } + + @Get("{branchId}") + async getById(@Path() branchId: string) { + const record = await prisma.customerBranch.findFirst({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: branchId }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "data_not_found"); + } + + return record; + } + + @Get("{branchId}/employee") + async listEmployee( + @Path() branchId: string, + @Query() zipCode?: string, + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + AND: { customerBranchId: branchId }, + OR: [ + { firstName: { contains: query }, zipCode }, + { firstNameEN: { contains: query }, zipCode }, + { lastName: { contains: query }, zipCode }, + { lastNameEN: { contains: query }, zipCode }, + { email: { contains: query }, zipCode }, + ], + } satisfies Prisma.EmployeeWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.employee.findMany({ + orderBy: { createdAt: "asc" }, + include: { + province: true, + district: true, + subDistrict: true, + }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.employee.count({ where }), + ]); + + return { + result: await Promise.all( + result.map(async (v) => ({ + ...v, + profileImageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(v.id), + 12 * 60 * 60, + ), + })), + ), + page, + pageSize, + total, + }; + } + + @Post() + async create(@Request() req: RequestWithUser, @Body() body: CustomerBranchCreate) { + if (body.provinceId || body.districtId || body.subDistrictId || body.customerId) { + const [province, district, subDistrict, customer] = 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.customer.findFirst({ where: { id: body.customerId || 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", + ); + if (body.customerId && !customer) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Customer cannot be found.", + "missing_or_invalid_parameter", + ); + } + + const { provinceId, districtId, subDistrictId, customerId, ...rest } = body; + + const count = await prisma.customerBranch.count({ + where: { customerId }, + }); + + const record = await prisma.customerBranch.create({ + include: { + province: true, + district: true, + subDistrict: true, + }, + data: { + ...rest, + branchNo: `${count + 1}`, + 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, + updateBy: req.user.name, + }, + }); + + await prisma.customer.updateMany({ + where: { id: customerId, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Put("{branchId}") + async editById( + @Request() req: RequestWithUser, + @Body() body: CustomerBranchUpdate, + @Path() branchId: string, + ) { + if (body.provinceId || body.districtId || body.subDistrictId || body.customerId) { + const [province, district, subDistrict, customer] = 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.customer.findFirst({ where: { id: body.customerId || 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", + ); + if (body.customerId && !customer) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Customer cannot be found.", + "missing_or_invalid_parameter", + ); + } + + const { provinceId, districtId, subDistrictId, customerId, ...rest } = body; + + if (!(await prisma.customerBranch.findUnique({ where: { id: branchId } }))) { + throw new HttpError(HttpStatus.NOT_FOUND, "Branch cannot be found.", "data_not_found"); + } + + const record = await prisma.customerBranch.update({ + where: { id: branchId }, + include: { + province: true, + district: true, + subDistrict: true, + }, + data: { + ...rest, + customer: { connect: customerId ? { id: customerId } : undefined }, + province: { + connect: provinceId ? { id: provinceId } : undefined, + disconnect: provinceId === null || undefined, + }, + district: { + connect: districtId ? { id: districtId } : undefined, + disconnect: districtId === null || undefined, + }, + subDistrict: { + connect: subDistrictId ? { id: subDistrictId } : undefined, + disconnect: subDistrictId === null || undefined, + }, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Delete("{branchId}") + async delete(@Path() branchId: string) { + const record = await prisma.customerBranch.findFirst({ where: { id: branchId } }); + + if (!record) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Customer branch cannot be found.", + "data_not_found", + ); + } + + if (record.status !== Status.CREATED) { + throw new HttpError(HttpStatus.FORBIDDEN, "Customer branch is in used.", "data_in_used"); + } + + return await prisma.customerBranch.delete({ where: { id: branchId } }); + } +} diff --git a/src/controllers/customer-controller.ts b/src/controllers/customer-controller.ts new file mode 100644 index 0000000..4be81cf --- /dev/null +++ b/src/controllers/customer-controller.ts @@ -0,0 +1,178 @@ +import { CustomerType, 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 minio from "../services/minio"; +import HttpStatus from "../interfaces/http-status"; +import HttpError from "../interfaces/http-error"; + +if (!process.env.MINIO_BUCKET) { + throw Error("Require MinIO bucket."); +} + +const MINIO_BUCKET = process.env.MINIO_BUCKET; + +export type CustomerCreate = { + status?: Status; + customerType: CustomerType; + customerName: string; + customerNameEN: string; +}; + +export type CustomerUpdate = { + status?: "ACTIVE" | "INACTIVE"; + customerType?: CustomerType; + customerName?: string; + customerNameEN?: string; +}; + +function imageLocation(id: string) { + return `customer/img-${id}`; +} + +@Route("api/customer") +@Tags("Customer") +@Security("keycloak") +export class CustomerController extends Controller { + @Get() + async list( + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + OR: [{ customerName: { contains: query } }, { customerNameEN: { contains: query } }], + } satisfies Prisma.CustomerWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.customer.findMany({ + orderBy: { createdAt: "asc" }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.customer.count({ where }), + ]); + + return { + result: await Promise.all( + result.map(async (v) => ({ + ...v, + imageUrl: await minio.presignedGetObject(MINIO_BUCKET, imageLocation(v.id), 12 * 60 * 60), + })), + ), + page, + pageSize, + total, + }; + } + + @Get("{customerId}") + async getById(@Path() customerId: string) { + const record = await prisma.customer.findFirst({ where: { id: customerId } }); + if (!record) + throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "data_not_found"); + return Object.assign(record, { + imageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + }); + } + + @Post() + async create(@Request() req: RequestWithUser, @Body() body: CustomerCreate) { + const last = await prisma.customer.findFirst({ + orderBy: { createdAt: "desc" }, + where: { customerType: body.customerType }, + }); + + const code = `${body.customerType}${(+(last?.code.slice(-6) || 0) + 1).toString().padStart(6, "0")}`; + + const record = await prisma.customer.create({ + data: { + ...body, + code, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return Object.assign(record, { + imageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + imageUploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + }); + } + + @Put("{customerId}") + async editById( + @Path() customerId: string, + @Request() req: RequestWithUser, + @Body() body: CustomerUpdate, + ) { + if (!(await prisma.customer.findUnique({ where: { id: customerId } }))) { + throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "data_not_found"); + } + + const record = await prisma.customer.update({ + where: { id: customerId }, + data: { + ...body, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + return Object.assign(record, { + imageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + imageUploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + }); + } + + @Delete("{customerId}") + async deleteById(@Path() customerId: string) { + const record = await prisma.customer.findFirst({ where: { id: customerId } }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Customer cannot be found.", "data_not_found"); + } + + if (record.status !== Status.CREATED) { + throw new HttpError(HttpStatus.FORBIDDEN, "Customer is in used.", "data_in_used"); + } + + return await prisma.customer.delete({ where: { id: customerId } }); + } +} diff --git a/src/controllers/employee-checkup-controller.ts b/src/controllers/employee-checkup-controller.ts new file mode 100644 index 0000000..de683d8 --- /dev/null +++ b/src/controllers/employee-checkup-controller.ts @@ -0,0 +1,183 @@ +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Put, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { RequestWithUser } from "../interfaces/user"; +import prisma from "../db"; +import HttpStatus from "../interfaces/http-status"; +import HttpError from "../interfaces/http-error"; + +type EmployeeCheckupCreate = { + checkupType: string; + checkupResult: string; + + provinceId?: string | null; + + hospitalName: string; + remark: string; + medicalBenefitScheme: string; + insuranceCompany: string; + coverageStartDate: Date; + coverageExpireDate: Date; +}; + +type EmployeeCheckupEdit = { + checkupType?: string; + checkupResult?: string; + + provinceId?: string | null; + + hospitalName?: string; + remark?: string; + medicalBenefitScheme?: string; + insuranceCompany?: string; + coverageStartDate?: Date; + coverageExpireDate?: Date; +}; + +@Route("api/employee/{employeeId}/checkup") +@Tags("Employee Checkup") +@Security("keycloak") +export class EmployeeCheckupController extends Controller { + @Get() + async list(@Path() employeeId: string) { + return prisma.employeeCheckup.findMany({ + orderBy: { createdAt: "asc" }, + where: { employeeId }, + }); + } + + @Get("{checkupId}") + async getById(@Path() employeeId: string, @Path() checkupId: string) { + const record = await prisma.employeeCheckup.findFirst({ + where: { id: checkupId, employeeId }, + }); + if (!record) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Employee checkup cannot be found.", + "data_not_found", + ); + } + return record; + } + + @Post() + async create( + @Request() req: RequestWithUser, + @Path() employeeId: string, + @Body() body: EmployeeCheckupCreate, + ) { + if (body.provinceId || employeeId) { + const [province, employee] = await prisma.$transaction([ + prisma.province.findFirst({ where: { id: body.provinceId || undefined } }), + prisma.employee.findFirst({ where: { id: employeeId } }), + ]); + if (body.provinceId && !province) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Province cannot be found.", + "missing_or_invalid_parameter", + ); + if (!employee) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Employee cannot be found.", + "missing_or_invalid_parameter", + ); + } + + const { provinceId, ...rest } = body; + + const record = await prisma.employeeCheckup.create({ + include: { province: true }, + data: { + ...rest, + province: { connect: provinceId ? { id: provinceId } : undefined }, + employee: { connect: { id: employeeId } }, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Put("{checkupId}") + async editById( + @Request() req: RequestWithUser, + @Path() employeeId: string, + @Path() checkupId: string, + @Body() body: EmployeeCheckupEdit, + ) { + if (body.provinceId || employeeId) { + const [province, employee] = await prisma.$transaction([ + prisma.province.findFirst({ where: { id: body.provinceId || undefined } }), + prisma.employee.findFirst({ where: { id: employeeId } }), + ]); + if (body.provinceId && !province) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Province cannot be found.", + "missing_or_invalid_parameter", + ); + if (!employee) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Employee cannot be found.", + "missing_or_invalid_parameter", + ); + } + + const { provinceId, ...rest } = body; + + if (!(await prisma.employeeCheckup.findUnique({ where: { id: checkupId, employeeId } }))) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Employee checkup cannot be found.", + "data_not_found", + ); + } + + const record = await prisma.employeeCheckup.update({ + include: { province: true }, + where: { id: checkupId, employeeId }, + data: { + ...rest, + province: { connect: provinceId ? { id: provinceId } : undefined }, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Delete("{checkupId}") + async deleteById(@Path() employeeId: string, @Path() checkupId: string) { + const record = await prisma.employeeCheckup.findFirst({ where: { id: checkupId, employeeId } }); + + if (!record) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Employee checkup cannot be found.", + "data_not_found", + ); + } + + return await prisma.employeeCheckup.delete({ where: { id: checkupId, employeeId } }); + } +} diff --git a/src/controllers/employee-controller.ts b/src/controllers/employee-controller.ts new file mode 100644 index 0000000..aeff13e --- /dev/null +++ b/src/controllers/employee-controller.ts @@ -0,0 +1,330 @@ +import { Prisma, Status } from "@prisma/client"; +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { RequestWithUser } from "../interfaces/user"; +import prisma from "../db"; +import HttpStatus from "../interfaces/http-status"; +import HttpError from "../interfaces/http-error"; +import minio from "../services/minio"; + +if (!process.env.MINIO_BUCKET) { + throw Error("Require MinIO bucket."); +} + +const MINIO_BUCKET = process.env.MINIO_BUCKET; + +function imageLocation(id: string) { + return `employee/profile-img-${id}`; +} + +type EmployeeCreate = { + customerBranchId: string; + + status?: Status; + + code: string; + nrcNo: string; + + dateOfBirth: Date; + gender: string; + nationality: string; + + firstName: string; + firstNameEN: string; + lastName: string; + lastNameEN: string; + + addressEN: string; + address: string; + zipCode: string; + email: string; + telephoneNo: string; + + arrivalBarricade: string; + arrivalCardNo: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; +}; + +type EmployeeUpdate = { + customerBranchId?: string; + status?: "ACTIVE" | "INACTIVE"; + + code?: string; + nrcNo?: string; + + dateOfBirth?: Date; + gender?: string; + nationality?: string; + + firstName?: string; + firstNameEN?: string; + lastName?: string; + lastNameEN?: string; + + addressEN?: string; + address?: string; + zipCode?: string; + email?: string; + telephoneNo?: string; + + arrivalBarricade?: string; + arrivalCardNo?: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; +}; + +@Route("api/employee") +@Tags("Employee") +@Security("keycloak") +export class EmployeeController extends Controller { + @Get() + async list( + @Query() zipCode?: string, + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + OR: [ + { firstName: { contains: query }, zipCode }, + { firstNameEN: { contains: query }, zipCode }, + { lastName: { contains: query }, zipCode }, + { lastNameEN: { contains: query }, zipCode }, + { email: { contains: query }, zipCode }, + ], + } satisfies Prisma.EmployeeWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.employee.findMany({ + orderBy: { createdAt: "asc" }, + include: { + province: true, + district: true, + subDistrict: true, + }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.employee.count({ where }), + ]); + + return { + result: await Promise.all( + result.map(async (v) => ({ + ...v, + profileImageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(v.id), + 12 * 60 * 60, + ), + })), + ), + page, + pageSize, + total, + }; + } + + @Get("{employeeId}") + async getById(@Path() employeeId: string) { + const record = await prisma.employee.findFirst({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: employeeId }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Employee cannot be found.", "data_not_found"); + } + + return record; + } + + @Post() + async create(@Request() req: RequestWithUser, @Body() body: EmployeeCreate) { + if (body.provinceId || body.districtId || body.subDistrictId || body.customerBranchId) { + const [province, district, subDistrict, customerBranch] = await prisma.$transaction([ + prisma.province.findFirst({ where: { id: body.provinceId || undefined } }), + prisma.district.findFirst({ where: { id: body.districtId || undefined } }), + prisma.subDistrict.findFirst({ where: { id: body.subDistrictId || undefined } }), + prisma.customerBranch.findFirst({ where: { id: body.customerBranchId || 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", + ); + if (body.customerBranchId && !customerBranch) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Customer Branch cannot be found.", + "missing_or_invalid_parameter", + ); + } + + const { provinceId, districtId, subDistrictId, customerBranchId, ...rest } = body; + + const record = await prisma.employee.create({ + include: { + province: true, + district: true, + subDistrict: true, + }, + data: { + ...rest, + province: { connect: provinceId ? { id: provinceId } : undefined }, + district: { connect: districtId ? { id: districtId } : undefined }, + subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined }, + customerBranch: { connect: { id: customerBranchId } }, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + await prisma.customerBranch.updateMany({ + where: { id: customerBranchId, status: Status.CREATED }, + data: { status: Status.ACTIVE }, + }); + + this.setStatus(HttpStatus.CREATED); + + return Object.assign(record, { + profileImageUrl: await minio.presignedPutObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + profileImageUploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + }); + } + + @Put("{employeeId}") + async editById( + @Request() req: RequestWithUser, + @Body() body: EmployeeUpdate, + @Path() employeeId: string, + ) { + if (body.provinceId || body.districtId || body.subDistrictId || body.customerBranchId) { + const [province, district, subDistrict, customerBranch] = await prisma.$transaction([ + prisma.province.findFirst({ where: { id: body.provinceId || undefined } }), + prisma.district.findFirst({ where: { id: body.districtId || undefined } }), + prisma.subDistrict.findFirst({ where: { id: body.subDistrictId || undefined } }), + prisma.customerBranch.findFirst({ where: { id: body.customerBranchId || 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", + ); + if (body.customerBranchId && !customerBranch) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Customer cannot be found.", + "missing_or_invalid_parameter", + ); + } + + const { provinceId, districtId, subDistrictId, customerBranchId, ...rest } = body; + + const record = await prisma.employee.update({ + where: { id: employeeId }, + include: { + province: true, + district: true, + subDistrict: true, + }, + data: { + ...rest, + customerBranch: { connect: customerBranchId ? { id: customerBranchId } : undefined }, + province: { + connect: provinceId ? { id: provinceId } : undefined, + disconnect: provinceId === null || undefined, + }, + district: { + connect: districtId ? { id: districtId } : undefined, + disconnect: districtId === null || undefined, + }, + subDistrict: { + connect: subDistrictId ? { id: subDistrictId } : undefined, + disconnect: subDistrictId === null || undefined, + }, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Delete("{employeeId}") + async delete(@Path() employeeId: string) { + const record = await prisma.employee.findFirst({ where: { id: employeeId } }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Employee cannot be found.", "data_not_found"); + } + + if (record.status !== Status.CREATED) { + throw new HttpError( + HttpStatus.FORBIDDEN, + "Emplyee is in used.", + "missing_or_invalid_parameter", + ); + } + + return await prisma.employee.delete({ where: { id: employeeId } }); + } +} diff --git a/src/controllers/employee-other-info-controller.ts b/src/controllers/employee-other-info-controller.ts new file mode 100644 index 0000000..4445c17 --- /dev/null +++ b/src/controllers/employee-other-info-controller.ts @@ -0,0 +1,127 @@ +import { Prisma, Status } from "@prisma/client"; +import { + Body, + Controller, + Delete, + Get, + Put, + Path, + Post, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; + +import prisma from "../db"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { RequestWithUser } from "../interfaces/user"; + +type EmployeeOtherInfoCreate = { + citizenId: string; + fatherFullName: string; + motherFullName: string; + birthPlace: string; +}; + +type EmployeeOtherInfoUpdate = { + citizenId: string; + fatherFullName: string; + motherFullName: string; + birthPlace: string; +}; + +@Route("api/employee/{employeeId}/other-info") +@Tags("Employee Other Info") +@Security("keycloak") +export class EmployeeOtherInfo extends Controller { + @Get() + async list(@Path() employeeId: string) { + return prisma.employeeOtherInfo.findMany({ + orderBy: { createdAt: "asc" }, + where: { employeeId }, + }); + } + + @Get("{otherInfoId}") + async getById(@Path() employeeId: string, @Path() otherInfoId: string) { + const record = await prisma.employeeOtherInfo.findFirst({ + where: { id: otherInfoId, employeeId }, + }); + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "Employee info cannot be found.", "data_not_found"); + } + return record; + } + + @Post() + async create( + @Request() req: RequestWithUser, + @Path() employeeId: string, + @Body() body: EmployeeOtherInfoCreate, + ) { + if (!(await prisma.employee.findUnique({ where: { id: employeeId } }))) + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Employee cannot be found.", + "missing_or_invalid_parameter", + ); + + const record = await prisma.employeeOtherInfo.create({ + data: { + ...body, + employee: { connect: { id: employeeId } }, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Put("{otherInfoId}") + async editById( + @Request() req: RequestWithUser, + @Path() employeeId: string, + @Path() otherInfoId: string, + @Body() body: EmployeeOtherInfoUpdate, + ) { + if (!(await prisma.employeeOtherInfo.findUnique({ where: { id: otherInfoId, employeeId } }))) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Employee other info cannot be found.", + "data_not_found", + ); + } + + const record = await prisma.employeeOtherInfo.update({ + where: { id: otherInfoId, employeeId }, + data: { ...body, createdBy: req.user.name, updateBy: req.user.name }, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Delete("{otherInfoId}") + async deleteById(@Path() employeeId: string, @Path() otherInfoId: string) { + const record = await prisma.employeeOtherInfo.findFirst({ + where: { id: otherInfoId, employeeId }, + }); + + if (!record) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Employee other info cannot be found.", + "data_not_found", + ); + } + + return await prisma.employeeOtherInfo.delete({ where: { id: otherInfoId, employeeId } }); + } +} diff --git a/src/controllers/keycloak-controller.ts b/src/controllers/keycloak-controller.ts new file mode 100644 index 0000000..5ebb09f --- /dev/null +++ b/src/controllers/keycloak-controller.ts @@ -0,0 +1,76 @@ +import { Body, Controller, Delete, Get, Path, Post, Put, Route, Security, Tags } from "tsoa"; +import { + addUserRoles, + createUser, + deleteUser, + editUser, + getRoles, + removeUserRoles, +} from "../services/keycloak"; + +@Route("api/keycloak") +@Tags("Single-Sign On") +@Security("keycloak") +export class KeycloakController extends Controller { + @Post("user") + async createUser( + @Body() body: { username: string; password: string; firstName?: string; lastName?: string }, + ) { + return await createUser(body.username, body.password, { + firstName: body.firstName, + lastName: body.lastName, + requiredActions: ["UPDATE_PASSWORD"], + }); + } + + @Put("user/{userId}") + async editUser( + @Path() userId: string, + @Body() body: { username?: string; password?: string; firstName?: string; lastName?: string }, + ) { + return await editUser(userId, body); + } + + @Delete("user/{userId}") + async deleteUser(@Path() userId: string) { + return await deleteUser(userId); + } + + @Get("role") + async getRole() { + const role = await getRoles(); + if (Array.isArray(role)) + return role.filter( + (a) => + !["uma_authorization", "offline_access", "default-roles"].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(); + + if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); + + const result = await addUserRoles( + userId, + list.filter((v) => body.role.includes(v.id)), + ); + + if (!result) throw new Error("Failed. Cannot set user's role."); + } + + @Delete("{userId}/role/{roleId}") + async deleteRole(@Path() userId: string, @Path() roleId: string) { + const list = await getRoles(); + + if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); + + const result = await removeUserRoles( + userId, + list.filter((v) => roleId === v.id), + ); + if (!result) throw new Error("Failed. Cannot remove user's role."); + } +} diff --git a/src/controllers/permission-controller.ts b/src/controllers/permission-controller.ts new file mode 100644 index 0000000..8d9b17c --- /dev/null +++ b/src/controllers/permission-controller.ts @@ -0,0 +1,178 @@ +import { Body, Controller, Delete, Get, Path, Post, Put, Route, Security, Tags } from "tsoa"; +import prisma from "../db"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; + +type MenuCreate = { + caption: string; + captionEN: string; + menuType: string; + url: string; + parentId?: string; +}; + +type MenuEdit = { + caption: string; + captionEN: string; + menuType: string; + url: string; +}; + +@Route("v1/permission/menu") +@Tags("Permission") +@Security("keycloak") +export class MenuController extends Controller { + @Get() + async listMenu() { + const record = await prisma.menu.findMany({ + include: { children: true, roleMenuPermission: true }, + orderBy: { createdAt: "asc" }, + }); + return record; + } + + @Post() + async createMenu(@Body() body: MenuCreate) { + if (body.parentId) { + const parent = await prisma.menu.findFirst({ where: { id: body.parentId } }); + + if (!parent) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Parent menu not found.", + "missing_or_invalid_parameter", + ); + } + } + + const record = await prisma.menu.create({ + include: { parent: true }, + data: body, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Put("{menuId}") + async editMenu(@Path("menuId") id: string, @Body() body: MenuEdit) { + const record = await prisma.menu + .update({ + include: { parent: true }, + where: { id }, + data: body, + }) + .catch((e) => { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new HttpError(HttpStatus.NOT_FOUND, "Menu cannot be found.", "data_not_found"); + } + throw new Error(e); + }); + + return record; + } + + @Delete("{menuId}") + async deleteMenu(@Path("menuId") id: string) { + const record = await prisma.menu.deleteMany({ where: { id } }); + if (record.count <= 0) { + throw new HttpError(HttpStatus.NOT_FOUND, "Menu cannot be found.", "data_not_found"); + } + } +} + +type MenuComponentCreate = { + componentId: string; + componentTag: string; + menuId: string; +}; + +type MenuComponentEdit = { + componentId?: string; + componentTag?: string; + menuId?: string; +}; + +@Route("v1/permission/menu-component") +@Tags("Permission") +@Security("keycloak") +export class MenuComponentController extends Controller { + @Get() + async listMenuComponent() { + const record = await prisma.menuComponent.findMany({ + include: { roleMenuComponentPermission: true }, + orderBy: { createdAt: "asc" }, + }); + + return record; + } + + @Post() + async createMenuComponent(@Body() body: MenuComponentCreate) { + const menu = await prisma.menu.findFirst({ where: { id: body.menuId } }); + + if (!menu) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Menu not found.", + "missing_or_invalid_parameter", + ); + } + + const record = await prisma.menuComponent.create({ + data: body, + }); + + this.setStatus(HttpStatus.CREATED); + + return record; + } + + @Put("{menuComponentId}") + async editMenuComponent(@Path("menuComponentId") id: string, @Body() body: MenuComponentEdit) { + if (body.menuId) { + const menu = await prisma.menu.findFirst({ where: { id: body.menuId } }); + + if (!menu) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Menu not found.", + "missing_or_invalid_parameter", + ); + } + } + + const record = await prisma.menuComponent + .update({ + include: { roleMenuComponentPermission: true }, + where: { id }, + data: body, + }) + .catch((e) => { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Menu component cannot be found.", + "data_not_found", + ); + } + throw new Error(e); + }); + + return record; + } + + @Delete("{menuComponentId}") + async deleteMenuComponent(@Path("menuComponentId") id: string) { + const record = await prisma.menuComponent.deleteMany({ where: { id } }); + if (record.count <= 0) { + throw new HttpError( + HttpStatus.NOT_FOUND, + "Menu component cannot be found.", + "data_not_found", + ); + } + } +} diff --git a/src/controllers/user-controller.ts b/src/controllers/user-controller.ts new file mode 100644 index 0000000..a31b0a8 --- /dev/null +++ b/src/controllers/user-controller.ts @@ -0,0 +1,576 @@ +import { + Body, + Controller, + Delete, + Get, + Put, + Path, + Post, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { Prisma, Status, UserType } from "@prisma/client"; + +import prisma from "../db"; +import minio from "../services/minio"; +import { RequestWithUser } from "../interfaces/user"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { + addUserRoles, + createUser, + deleteUser, + editUser, + getRoles, + getUserRoles, + removeUserRoles, +} from "../services/keycloak"; + +if (!process.env.MINIO_BUCKET) { + throw Error("Require MinIO bucket."); +} + +const MINIO_BUCKET = process.env.MINIO_BUCKET; + +type UserCreate = { + status?: Status; + + userType: UserType; + userRole: string; + + username: string; + + firstName: string; + firstNameEN: string; + lastName: string; + lastNameEN: string; + gender: string; + + checkpoint?: string | null; + checkpointEN?: string | null; + registrationNo?: string | null; + startDate?: Date | null; + retireDate?: Date | null; + discountCondition?: string | null; + licenseNo?: string | null; + licenseIssueDate?: Date | null; + licenseExpireDate?: Date | null; + sourceNationality?: string | null; + importNationality?: string | null; + trainingPlace?: string | null; + responsibleArea?: string | null; + birthDate?: Date | null; + + address: string; + addressEN: string; + zipCode: string; + email: string; + telephoneNo: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; +}; + +type UserUpdate = { + status?: "ACTIVE" | "INACTIVE"; + + username?: string; + + userType?: UserType; + userRole?: string; + + firstName?: string; + firstNameEN?: string; + lastName?: string; + lastNameEN?: string; + gender?: string; + + checkpoint?: string | null; + checkpointEN?: string | null; + registrationNo?: string | null; + startDate?: Date | null; + retireDate?: Date | null; + discountCondition?: string | null; + licenseNo?: string | null; + licenseIssueDate?: Date | null; + licenseExpireDate?: Date | null; + sourceNationality?: string | null; + importNationality?: string | null; + trainingPlace?: string | null; + responsibleArea?: string | null; + birthDate?: Date | null; + + address?: string; + addressEN?: string; + zipCode?: string; + email?: string; + telephoneNo?: string; + + subDistrictId?: string | null; + districtId?: string | null; + provinceId?: string | null; +}; + +function imageLocation(id: string) { + return `user/profile-img-${id}`; +} + +@Route("api/user") +@Tags("User") +@Security("keycloak") +export class UserController extends Controller { + @Get("type-stats") + async getUserTypeStats() { + const list = await prisma.user.groupBy({ + by: "userType", + _count: true, + }); + + return list.reduce>( + (a, c) => { + a[c.userType] = c._count; + return a; + }, + { + USER: 0, + MESSENGER: 0, + DELEGATE: 0, + AGENCY: 0, + }, + ); + } + + @Get() + async getUser( + @Query() userType?: UserType, + @Query() zipCode?: string, + @Query() includeBranch: boolean = false, + @Query() query: string = "", + @Query() page: number = 1, + @Query() pageSize: number = 30, + ) { + const where = { + OR: [ + { firstName: { contains: query }, zipCode, userType }, + { firstNameEN: { contains: query }, zipCode, userType }, + { lastName: { contains: query }, zipCode, userType }, + { lastNameEN: { contains: query }, zipCode, userType }, + { email: { contains: query }, zipCode, userType }, + { telephoneNo: { contains: query }, zipCode, userType }, + ], + } satisfies Prisma.UserWhereInput; + + const [result, total] = await prisma.$transaction([ + prisma.user.findMany({ + orderBy: { createdAt: "asc" }, + include: { + province: true, + district: true, + subDistrict: true, + branch: { include: { branch: includeBranch } }, + }, + where, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.user.count({ where }), + ]); + + return { + result: await Promise.all( + result.map(async (v) => ({ + ...v, + branch: includeBranch ? v.branch.map((a) => a.branch) : undefined, + profileImageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(v.id), + 12 * 60 * 60, + ), + })), + ), + page, + pageSize, + total, + }; + } + + @Get("{userId}") + async getUserById(@Path() userId: string) { + const record = await prisma.user.findFirst({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: userId }, + }); + + if (!record) + throw new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "data_not_found"); + + return Object.assign(record, { + profileImageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(record.id), + 60 * 60, + ), + }); + } + + @Post() + 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.", + "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 { provinceId, districtId, subDistrictId, username, ...rest } = body; + + let list = await getRoles(); + + if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); + if (Array.isArray(list)) { + list = list.filter( + (a) => + !["uma_authorization", "offline_access", "default-roles"].some((b) => a.name.includes(b)), + ); + } + + const userId = await createUser(username, username, { + firstName: body.firstName, + lastName: body.lastName, + requiredActions: ["UPDATE_PASSWORD"], + }); + + if (!userId || typeof userId !== "string") { + throw new Error("Cannot create user with keycloak service."); + } + + const role = list.find((v) => v.name === body.userRole); + + const resultAddRole = role && (await addUserRoles(userId, [role])); + + if (!resultAddRole) { + await deleteUser(userId); + throw new Error("Failed. Cannot set user's role."); + } + + const record = await prisma.user.create({ + include: { province: true, district: true, subDistrict: true }, + data: { + id: userId, + ...rest, + username, + userRole: role.name, + province: { connect: provinceId ? { id: provinceId } : undefined }, + district: { connect: districtId ? { id: districtId } : undefined }, + subDistrict: { connect: subDistrictId ? { id: subDistrictId } : undefined }, + createdBy: req.user.name, + updateBy: req.user.name, + }, + }); + + this.setStatus(HttpStatus.CREATED); + + return Object.assign(record, { + profileImageUrl: await minio.presignedPutObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + profileImageUploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + }); + } + + @Put("{userId}") + 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", + ); + } + + let userRole: string | undefined; + + if (body.userRole) { + let list = await getRoles(); + + if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); + if (Array.isArray(list)) { + list = list.filter( + (a) => + !["uma_authorization", "offline_access", "default-roles"].some((b) => + a.name.includes(b), + ), + ); + } + const currentRole = await getUserRoles(userId); + + const role = list.find((v) => v.name === body.userRole); + + const resultAddRole = role && (await addUserRoles(userId, [role])); + + if (!resultAddRole) { + throw new Error("Failed. Cannot set user's role."); + } else { + if (Array.isArray(currentRole)) await removeUserRoles(userId, currentRole); + } + + userRole = role.name; + } + + if (body.username) { + await editUser(userId, { username: body.username }); + } + + 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.", "data_not_found"); + } + + 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 record = await prisma.user.update({ + include: { province: true, district: true, subDistrict: true }, + data: { + ...rest, + 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, + }, + district: { + connect: districtId ? { id: districtId } : undefined, + disconnect: districtId === null || undefined, + }, + subDistrict: { + connect: subDistrictId ? { id: subDistrictId } : undefined, + disconnect: subDistrictId === null || undefined, + }, + updateBy: req.user.name, + }, + where: { id: userId }, + }); + + return Object.assign(record, { + profileImageUrl: await minio.presignedGetObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + profileImageUploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + imageLocation(record.id), + 12 * 60 * 60, + ), + }); + } + + @Delete("{userId}") + async deleteUser(@Path() userId: string) { + const record = await prisma.user.findFirst({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: userId }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "data_not_found"); + } + + if (record.status !== Status.CREATED) { + throw new HttpError(HttpStatus.FORBIDDEN, "User is in used.", "data_in_used"); + } + + await minio.removeObject(MINIO_BUCKET, imageLocation(userId), { + forceDelete: true, + }); + + new Promise((resolve, reject) => { + const item: string[] = []; + + const stream = minio.listObjectsV2(MINIO_BUCKET, `${attachmentLocation(userId)}/`); + + stream.on("data", (v) => v && v.name && item.push(v.name)); + stream.on("end", () => resolve(item)); + stream.on("error", () => reject(new Error("MinIO error."))); + }).then((list) => { + list.map(async (v) => { + await minio.removeObject(MINIO_BUCKET, `${attachmentLocation(userId)}/${v}`, { + forceDelete: true, + }); + }); + }); + + await deleteUser(userId); + + return await prisma.user.delete({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: userId }, + }); + } +} + +function attachmentLocation(uid: string) { + return `user-attachment/${uid}`; +} + +@Route("api/user/{userId}/attachment") +@Tags("User") +@Security("keycloak") +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 }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "data_not_found"); + } + + const list = await new Promise((resolve, reject) => { + const item: string[] = []; + + const stream = minio.listObjectsV2(MINIO_BUCKET, `${attachmentLocation(userId)}/`); + + stream.on("data", (v) => v && v.name && item.push(v.name)); + stream.on("end", () => resolve(item)); + stream.on("error", () => reject(new Error("MinIO error."))); + }); + + return await Promise.all( + list.map(async (v) => ({ + name: v.split("/").at(-1) as string, + url: await minio.presignedGetObject(MINIO_BUCKET, v, 12 * 60 * 60), + })), + ); + } + + @Post() + async addAttachment(@Path() userId: string, @Body() payload: { file: string[] }) { + const record = await prisma.user.findFirst({ + include: { + province: true, + district: true, + subDistrict: true, + }, + where: { id: userId }, + }); + + if (!record) { + throw new HttpError(HttpStatus.NOT_FOUND, "User cannot be found.", "data_not_found"); + } + + return await Promise.all( + payload.file.map(async (v) => ({ + name: v, + url: await minio.presignedGetObject(MINIO_BUCKET, `${attachmentLocation(userId)}/${v}`), + uploadUrl: await minio.presignedPutObject( + MINIO_BUCKET, + `${attachmentLocation(userId)}/${v}`, + 12 * 60 * 60, + ), + })), + ); + } + + @Delete() + async deleteAttachment(@Path() userId: string, @Body() payload: { file: string[] }) { + await Promise.all( + payload.file.map(async (v) => { + await minio.removeObject(MINIO_BUCKET, `${attachmentLocation(userId)}/${v}`, { + forceDelete: true, + }); + }), + ); + } +} diff --git a/src/interfaces/http-error.ts b/src/interfaces/http-error.ts index 5d396e2..e9f34fd 100644 --- a/src/interfaces/http-error.ts +++ b/src/interfaces/http-error.ts @@ -1,18 +1,29 @@ import HttpStatus from "./http-status"; +type DevMessage = + | "missing_or_invalid_parameter" + | "data_exists" + | "data_in_used" + | "no_permission" + | "unknown_url" + | "data_not_found" + | "unauthorized"; + class HttpError extends Error { /** * HTTP Status Code */ status: HttpStatus; message: string; + devMessage?: DevMessage; - constructor(status: HttpStatus, message: string) { + constructor(status: HttpStatus, message: string, devMessage?: DevMessage) { super(message); this.name = "HttpError"; this.status = status; this.message = message; + this.devMessage = devMessage; } } diff --git a/src/middlewares/auth-provider/keycloak.ts b/src/middlewares/auth-provider/keycloak.ts index 2c7359f..5201c36 100644 --- a/src/middlewares/auth-provider/keycloak.ts +++ b/src/middlewares/auth-provider/keycloak.ts @@ -16,11 +16,7 @@ const jwtVerify = createVerifier({ const jwtDecode = createDecoder(); -export async function keycloakAuth( - request: Express.Request, - _securityName?: string, - _scopes?: string[], -) { +export async function keycloakAuth(request: Express.Request, roles?: string[]) { const token = request.headers["authorization"]?.includes("Bearer ") ? request.headers["authorization"].split(" ")[1] : request.headers["authorization"]; @@ -39,7 +35,7 @@ export async function keycloakAuth( payload = await verifyOffline(token); break; default: - if (process.env.KC_REALM_URL) { + if (process.env.KC_URL) { payload = await verifyOnline(token); break; } @@ -49,6 +45,12 @@ export async function keycloakAuth( } } + 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, "คุณไม่มีสิทธิในการเข้าถึงข้อมูลดังกล่าว"); + } + } + return payload; } diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 75f3773..ad0bb1e 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -6,11 +6,11 @@ import { keycloakAuth } from "./auth-provider/keycloak"; export async function expressAuthentication( request: Express.Request, securityName: string, - _scopes?: string[], + scopes?: string[], ) { switch (securityName) { case "keycloak": - return keycloakAuth(request); + return keycloakAuth(request, scopes); default: throw new HttpError(HttpStatus.NOT_IMPLEMENTED, "ไม่ทราบวิธียืนยันตัวตน"); } diff --git a/src/middlewares/error.ts b/src/middlewares/error.ts index b010f0a..d80ceb3 100644 --- a/src/middlewares/error.ts +++ b/src/middlewares/error.ts @@ -8,6 +8,7 @@ function error(error: Error, _req: Request, res: Response, _next: NextFunction) return res.status(error.status).json({ status: error.status, message: error.message, + devMessage: error.devMessage, }); } @@ -16,6 +17,7 @@ function error(error: Error, _req: Request, res: Response, _next: NextFunction) status: HttpStatus.UNPROCESSABLE_ENTITY, message: "Validation error(s).", detail: error.fields, + devMessage: "missing_or_invalid_parameter", }); } @@ -24,6 +26,7 @@ function error(error: Error, _req: Request, res: Response, _next: NextFunction) return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ status: HttpStatus.INTERNAL_SERVER_ERROR, message: error.message, + devMessage: "system_error", }); } diff --git a/src/middlewares/log.ts b/src/middlewares/log.ts new file mode 100644 index 0000000..3cd45f5 --- /dev/null +++ b/src/middlewares/log.ts @@ -0,0 +1,79 @@ +import { NextFunction, Request, Response } from "express"; +import elasticsearch from "../services/elasticsearch"; + +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 = { + 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: req.headers["x-tid"], + method: req.method, + endpoint: req.url, + responseCode: res.statusCode, + responseDescription: + data?.devMessage !== undefined + ? data.devMessage + : { 200: "success", 201: "created_success", 204: "no_content", 304: "success" }[ + res.statusCode + ], + 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; diff --git a/src/services/elasticsearch.ts b/src/services/elasticsearch.ts new file mode 100644 index 0000000..529bc4f --- /dev/null +++ b/src/services/elasticsearch.ts @@ -0,0 +1,7 @@ +import { Client } from "@elastic/elasticsearch"; + +const elasticsearch = new Client({ + node: `${process.env.ELASTICSEARCH_PROTOCOL}://${process.env.ELASTICSEARCH_HOST}:${process.env.ELASTICSEARCH_PORT}`, +}); + +export default elasticsearch; diff --git a/src/services/keycloak.ts b/src/services/keycloak.ts index 7a5ce4a..db7b4b5 100644 --- a/src/services/keycloak.ts +++ b/src/services/keycloak.ts @@ -91,6 +91,63 @@ export async function createUser(username: string, password: string, opts?: Reco return id || true; } +/** + * Update keycloak user by uuid + * + * Client must have permission to manage realm's user + * + * @returns user uuid or true if success, false otherwise. + */ +export async function editUser(userId: string, opts: Record) { + const { password, ...rest } = opts; + + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "PUT", + body: JSON.stringify({ + enabled: true, + credentials: (password && [{ type: "password", value: opts?.password }]) || undefined, + ...rest, + }), + }).catch((e) => console.log("Keycloak Error: ", e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + const path = res.headers.get("Location"); + const id = path?.split("/").at(-1); + return id || true; +} + +/** + * Delete keycloak user by uuid + * + * Client must have permission to manage realm's user + * + * @returns user uuid or true if success, false otherwise. + */ +export async function deleteUser(userId: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "DELETE", + }).catch((e) => console.log("Keycloak Error: ", e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } +} + /** * Get roles list or specific role data * @@ -121,7 +178,46 @@ export async function getRoles(name?: string) { const data = await res.json(); if (Array.isArray(data)) { - return data.map((v: Record) => ({ id: v.id, name: v.name })); + return data.map((v: Record) => ({ id: v.id, name: v.name })); + } + + return { + id: data.id, + name: data.name, + }; +} + +/** + * Get roles list of user + * + * 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 getUserRoles(userId: string) { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/role-mappings/realm`, + { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + }, + ).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + if (res.status === 404) { + return null; + } + + const data = await res.json(); + + if (Array.isArray(data)) { + return data.map((v: Record) => ({ id: v.id, name: v.name })); } return { @@ -137,7 +233,7 @@ export async function getRoles(name?: string) { * * @returns true if success, false otherwise. */ -export async function addUserRoles(userId: string, roleId: string[]) { +export async function addUserRoles(userId: string, roles: { id: string; name: string }[]) { const res = await fetch( `${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/role-mappings/realm`, { @@ -147,7 +243,7 @@ export async function addUserRoles(userId: string, roleId: string[]) { "content-type": `application/json`, }, method: "POST", - body: JSON.stringify(roleId.map((v) => ({ id: v }))), + body: JSON.stringify(roles), }, ).catch((e) => console.log(e)); @@ -165,7 +261,7 @@ export async function addUserRoles(userId: string, roleId: string[]) { * * @returns true if success, false otherwise. */ -export async function removeUserRoles(userId: string, roleId: string[]) { +export async function removeUserRoles(userId: string, roles: { id: string; name: string }[]) { const res = await fetch( `${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/role-mappings/realm`, { @@ -175,7 +271,7 @@ export async function removeUserRoles(userId: string, roleId: string[]) { "content-type": `application/json`, }, method: "DELETE", - body: JSON.stringify(roleId.map((v) => ({ id: v }))), + body: JSON.stringify(roles), }, ).catch((e) => console.log(e)); diff --git a/tsoa.json b/tsoa.json index 2f059de..694176c 100644 --- a/tsoa.json +++ b/tsoa.json @@ -12,6 +12,28 @@ "description": "Keycloak Bearer Token", "in": "header" } + }, + "spec": { + "tags": [ + { "name": "OpenAPI" }, + { "name": "Single-Sign On" }, + { "name": "Permission" }, + { "name": "Address" }, + { "name": "Branch" }, + { "name": "Branch Contact" }, + { "name": "User" }, + { "name": "Branch User" }, + { "name": "Customer" }, + { "name": "Customer Branch" }, + { "name": "Employee" }, + { "name": "Employee Checkup" }, + { "name": "Employee Work" }, + { "name": "Employee Other Info" }, + { "name": "Service" }, + { "name": "Work" }, + { "name": "Product Type" }, + { "name": "Product Group" } + ] } }, "routes": {