diff --git a/.github/workflows/Prod-Deployment.yaml b/.github/workflows/Prod-Deployment.yaml deleted file mode 100644 index 670461aa..00000000 --- a/.github/workflows/Prod-Deployment.yaml +++ /dev/null @@ -1,89 +0,0 @@ -name: PROD-BACKEND-TAG-BASE-DEPLOYMENT - -on: - push: - tags: - - 'v*' # This will trigger on tags starting with 'v' - -env: - ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} - EKS_CLUSTER_NAME: ${{ secrets.EKS_CLUSTER_NAME_PROD }} - AWS_REGION: ${{ secrets.AWS_REGION_NAME }} - -jobs: - BACKEND-TAG-BASE-DEPLOYMENT-PROD: - name: Deployment - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Set TAG environment variable - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - - name: Debug TAG value - run: echo "TAG value - ${{ env.TAG }}" - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_PROD }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_PROD }} - aws-region: ${{ env.AWS_REGION }} - - - name: Setup Node Env - uses: actions/setup-node@v3 - with: - node-version: 21.1.0 - - - name: Copy .env file - env: - ENV_FILE_CONTENT: ${{ secrets.ENV_FILE_CONTENT_PROD }} - run: echo "$ENV_FILE_CONTENT" > manifest/configmap.yaml - - - name: Show PWD and list content and Latest 3 commits - run: | - echo "Fetching all branches to ensure complete history" - git fetch --all - echo "Checking out the current branch" - git checkout ${{ github.ref_name }} - echo "Git Branch cloned" - git branch - echo "Current 3 merge commits are:" - git log --merges -n 3 - pwd - ls -ltra - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 - - - name: Build, tag, and push image to Amazon ECR - env: - ECR_REGISTRY: ${{ secrets.ECR_REPOSITORY }} - IMAGE_TAG: ${{ env.TAG }} - run: | - docker build -t ${{ secrets.ECR_REPOSITORY }}:${{ env.TAG }} . - docker push ${{ secrets.ECR_REPOSITORY }}:${{ env.TAG }} - - - name: Update kube config - run: aws eks update-kubeconfig --name ${{ secrets.EKS_CLUSTER_NAME_PROD }} --region ${{ secrets.AWS_REGION_NAME }} - - - name: Deploy to EKS - env: - ECR_REGISTRY: ${{ secrets.ECR_REPOSITORY }} - IMAGE_TAG: ${{ env.TAG }} - run: | - export ECR_REPOSITORY=${{ secrets.ECR_REPOSITORY }} - export IMAGE_TAG=${{ env.TAG }} - envsubst < manifest/backend.yaml > manifest/backend-updated.yaml - cat manifest/backend-updated.yaml - kubectl delete deployment backend - kubectl apply -f manifest/backend-updated.yaml - kubectl apply -f manifest/configmap.yaml - sleep 10 - kubectl get pods - kubectl get services - kubectl get deployment diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 00000000..569e16c2 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,58 @@ +name: Tag-based Image Build for QA and PROD + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + environment: + description: "Target environment (qa or prod)" + required: true + default: "qa" + tag: + description: "Image tag to deploy" + required: true + +jobs: + build: + name: Build and Push Docker Image to AWS ECR + runs-on: ubuntu-latest + + # Use qa for tag pushes, or the chosen input for workflow_dispatch + environment: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || 'qa' }} + + env: + ENVIRONMENT: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || 'qa' }} + TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Extract ECR Registry from Repository + run: | + FULL_REPO="${{ secrets.ECR_REPOSITORY }}" + REGISTRY="$(echo $FULL_REPO | cut -d'/' -f1)" + echo "ECR_REGISTRY=$REGISTRY" >> $GITHUB_ENV + echo "ECR_REPOSITORY=$FULL_REPO" >> $GITHUB_ENV + + - name: Log in to Amazon ECR + run: | + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} \ + | docker login --username AWS --password-stdin ${{ env.ECR_REGISTRY }} + + - name: Build, Tag, and Push Docker Image + run: | + IMAGE_URI="${{ env.ECR_REPOSITORY }}:${{ env.TAG }}" + echo "Building image: $IMAGE_URI" + docker build -t $IMAGE_URI . + docker push $IMAGE_URI + docker rmi $IMAGE_URI diff --git a/.github/workflows/tekdi-server-deployment.yaml b/.github/workflows/dev-deployment.yaml similarity index 81% rename from .github/workflows/tekdi-server-deployment.yaml rename to .github/workflows/dev-deployment.yaml index badaf466..78fb5d41 100644 --- a/.github/workflows/tekdi-server-deployment.yaml +++ b/.github/workflows/dev-deployment.yaml @@ -1,8 +1,8 @@ -name: Deploy to Tekdi-QA-Server +name: Deploy to Dev Server Dockerised on: push: branches: - - sdbv_rbac_changes + - main jobs: deploy: runs-on: ubuntu-latest @@ -16,12 +16,12 @@ jobs: username: ${{ secrets.USERNAME_TEKDI_QA }} key: ${{ secrets.EC2_SSH_KEY_TEKDI_QA }} port: ${{ secrets.PORT_TEKDI_QA }} - script: | + script: | cd ${{ secrets.TARGET_DIR_TEKDI_QA }} if [ -f .env ]; then rm .env fi - echo '${{ secrets.QA_ENV }}"' > .env + echo '${{ secrets.DEV_ENV }}"' > .env ls -ltra ./deploy.sh #Testing diff --git a/.github/workflows/dev-pratham-eks-deployment.yaml b/.github/workflows/dev-pratham-eks-deployment.yaml deleted file mode 100644 index a27bc03d..00000000 --- a/.github/workflows/dev-pratham-eks-deployment.yaml +++ /dev/null @@ -1,87 +0,0 @@ -name: Deploy to EKS-Pratham -on: - workflow_dispatch: -env: - ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} - EKS_CLUSTER_NAME: ${{ secrets.EKS_CLUSTER_NAME }} - AWS_REGION: ${{ secrets.AWS_REGION_NAME }} -jobs: - build: - - name: Deployment - runs-on: ubuntu-latest - steps: - - name: Set short git commit SHA - id: commit - uses: prompt/actions-commit-hash@v2 - - name: Check out code - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{env.AWS_REGION}} - - name: Setup Node Env - uses: actions/setup-node@v3 - with: - node-version: 21.1.0 - - name: Copy .env file - env: - ENV_FILE_CONTENT: ${{ secrets.ENV_FILE_CONTENT }} - run: printf "%s" "$ENV_FILE_CONTENT" > manifest/configmap.yaml - #echo "$ENV_FILE_CONTENT" > manifest/configmap.yaml - - name: Show PWD and list content and Latest 3 commits - run: | - echo "Fetching all branches to ensure complete history" - git fetch --all - echo "Checking out the current branch" - git checkout ${{ github.ref_name }} - echo "Git Branch cloned" - git branch - echo "Current 3 merge commits are:" - git log --merges -n 3 - pwd - ls -ltra - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ env.AWS_REGION }} - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 - - name: Build, tag, and push image to Amazon ECR - env: - ECR_REGISTRY: ${{ secrets.ECR_REPOSITORY }} - IMAGE_TAG: ${{ secrets.ECR_IMAGE }} - run: | - docker build -t ${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} . - docker push ${{ secrets.ECR_REPOSITORY }}:${{ secrets.IMAGE_TAG }} - - name: Update kube config - run: aws eks update-kubeconfig --name ${{ secrets.EKS_CLUSTER_NAME }} --region ${{ secrets.AWS_REGION_NAME }} - - name: Deploy to EKS - env: - ECR_REGISTRY: ${{ secrets.ECR_REPOSITORY }} - IMAGE_TAG: ${{ secrets.IMAGE_TAG }} - ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} - ECR_IMAGE: ${{ secrets.ECR_IMAGE }} - run: | - export ECR_REPOSITORY=${{ secrets.ECR_REPOSITORY }} - export IMAGE_TAG=${{ secrets.IMAGE_TAG }} - export ECR_IMAGE=${{ secrets.ECR_IMAGE }} - envsubst < manifest/backend.yaml > manifest/backend-updated.yaml - cat manifest/backend-updated.yaml - rm -rf manifest/backend-service.yaml - kubectl delete deployment backend - kubectl delete service backend - kubectl delete cm backend-service-config - kubectl apply -f manifest/backend-updated.yaml - kubectl apply -f manifest/configmap.yaml - sleep 10 - kubectl get pods - kubectl get services - kubectl get deployment diff --git a/.github/workflows/qa-prod-deployment.yaml b/.github/workflows/qa-prod-deployment.yaml new file mode 100644 index 00000000..13625bec --- /dev/null +++ b/.github/workflows/qa-prod-deployment.yaml @@ -0,0 +1,76 @@ +name: BACKEND-TAG-BASED-DEPLOYMENT-AWS-EKS + +on: + workflow_dispatch: + inputs: + environment: + description: "Target environment (qa or prod)" + required: true + default: "qa" + tag: + description: "Image tag to deploy" + required: true + +jobs: + deploy: + name: Deploy Backend Service + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment }} + + steps: + # Step 1: Checkout code + - name: Check out repository + uses: actions/checkout@v2 + + # Step 2: Set TAG environment variable + - name: Set TAG environment variable + run: | + TAG="${{ github.event.inputs.tag }}" + echo "TAG=$TAG" >> $GITHUB_ENV + + # Step 3: Debug TAG value + - name: Debug TAG value + run: echo "TAG value:${{ env.TAG }}" + + # Step 4: Configure AWS credentials + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + # Step 5: Decode ConfigMap manifest from secret + - name: Write ConfigMap manifest + run: | + mkdir -p manifest + echo "${{ secrets.ENV_FILE_CONTENT_BACKEND }}" | base64 -d > manifest/configmap.yaml + echo "Generated ConfigMap:" + cat manifest/configmap.yaml + + # Step 6: Update Deployment Manifest + - name: Update Deployment Manifest + env: + IMAGE_TAG: ${{ env.TAG }} + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} + run: | + mkdir -p manifest + envsubst < manifest/backend.yaml > manifest/backend-service-updated.yaml + echo "Updated deployment manifest:" + cat manifest/backend-service-updated.yaml + + # Step 7: Deploy to AWS EKS + - name: Deploy to AWS EKS + env: + EKS_CLUSTER_NAME: ${{ secrets.EKS_CLUSTER_NAME }} + AWS_REGION: ${{ secrets.AWS_REGION }} + NAMESPACE: ${{ github.event.inputs.environment == 'prod' && 'default' || 'microservices-qa' }} + run: | + aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --region $AWS_REGION + kubectl apply -f manifest/configmap.yaml -n $NAMESPACE + kubectl apply -f manifest/backend-service-updated.yaml -n $NAMESPACE + # Restart pods to pick up new config + kubectl rollout restart deployment backend -n $NAMESPACE + sleep 10 + echo "Pods status:" + kubectl get pods -n $NAMESPACE | grep backend diff --git a/package-lock.json b/package-lock.json index 043af0dc..b2533a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "@types/multer": "^1.4.12", "aws-sdk": "^2.1692.0", "axios": "^0.26.1", - "cache-manager": "^3.6.1", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "csv": "^6.3.10", @@ -36,6 +35,7 @@ "graphql-tag": "^2.12.6", "in-memory-faceted-search": "^1.0.1", "jwt-decode": "^3.1.2", + "kafkajs": "^2.2.4", "moment": "^2.30.1", "multer": "^1.4.4", "node-cron": "^3.0.1", @@ -50,13 +50,13 @@ "swagger-ui-express": "^4.3.0", "templates.js": "^0.3.11", "typeorm": "^0.3.20", + "uuid": "^11.1.0", "winston": "^3.11.0" }, "devDependencies": { "@nestjs/cli": "^8.0.0", "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", - "@types/cache-manager": "^3.4.3", "@types/cron": "^1.7.3", "@types/express": "^4.17.13", "@types/jest": "27.4.1", @@ -2545,6 +2545,15 @@ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "license": "0BSD" }, + "node_modules/@nestjs/common/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/config": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-2.3.4.tgz", @@ -2628,6 +2637,15 @@ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "license": "0BSD" }, + "node_modules/@nestjs/core/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/jwt": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-8.0.1.tgz", @@ -2780,6 +2798,15 @@ "reflect-metadata": "^0.1.12" } }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-8.0.8.tgz", @@ -5276,12 +5303,13 @@ } }, "node_modules/cache-manager": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-3.6.1.tgz", - "integrity": "sha512-jxJvGYhN5dUgpriAdsDnnYbKse4dEXI5i3XpwTfPq5utPtXH1uYXWyGLHGlbSlh9Vq4ytrgAUVwY+IodNeKigA==", + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-3.6.3.tgz", + "integrity": "sha512-dS4DnV6c6cQcVH5OxzIU1XZaACXwvVIiUPkFytnRmLOACuBGv3GQgRQ1RJGRRw4/9DF14ZK2RFlZu1TUgDniMg==", + "license": "MIT", "dependencies": { "async": "3.2.3", - "lodash": "^4.17.21", + "lodash.clonedeep": "^4.5.0", "lru-cache": "6.0.0" } }, @@ -7174,21 +7202,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8916,6 +8929,15 @@ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" }, + "node_modules/kafkajs": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz", + "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -9011,6 +9033,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -12037,12 +12065,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-compile-cache": { diff --git a/package.json b/package.json index 3a49ac9e..156d8152 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "@types/multer": "^1.4.12", "aws-sdk": "^2.1692.0", "axios": "^0.26.1", - "cache-manager": "^3.6.1", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "csv": "^6.3.10", @@ -48,6 +47,7 @@ "graphql-tag": "^2.12.6", "in-memory-faceted-search": "^1.0.1", "jwt-decode": "^3.1.2", + "kafkajs": "^2.2.4", "moment": "^2.30.1", "multer": "^1.4.4", "node-cron": "^3.0.1", @@ -62,15 +62,13 @@ "swagger-ui-express": "^4.3.0", "templates.js": "^0.3.11", "typeorm": "^0.3.20", - "winston": "^3.11.0", - "kafkajs": "^2.2.4" - + "uuid": "^11.1.0", + "winston": "^3.11.0" }, "devDependencies": { "@nestjs/cli": "^8.0.0", "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", - "@types/cache-manager": "^3.4.3", "@types/cron": "^1.7.3", "@types/express": "^4.17.13", "@types/jest": "27.4.1", diff --git a/src/adapters/postgres/cohort-adapter.ts b/src/adapters/postgres/cohort-adapter.ts index ee8a579a..3a158da1 100644 --- a/src/adapters/postgres/cohort-adapter.ts +++ b/src/adapters/postgres/cohort-adapter.ts @@ -24,6 +24,7 @@ import { CohortAcademicYear } from "src/cohortAcademicYear/entities/cohortAcadem import { PostgresCohortMembersService } from "./cohortMembers-adapter"; import { LoggerUtil } from "src/common/logger/LoggerUtil"; import { AutomaticMemberService } from "src/automatic-member/automatic-member.service"; +import { KafkaService } from "../../kafka/kafka.service"; @Injectable() export class PostgresCohortService { @@ -42,7 +43,8 @@ export class PostgresCohortService { private readonly cohortAcademicYearService: CohortAcademicYearService, private readonly postgresAcademicYearService: PostgresAcademicYearService, private readonly postgresCohortMembersService: PostgresCohortMembersService, - private readonly automaticMemberService: AutomaticMemberService + private readonly automaticMemberService: AutomaticMemberService, + private readonly kafkaService: KafkaService ) { } public async getCohortsDetails(requiredData, res) { @@ -425,13 +427,24 @@ export class PostgresCohortService { API_RESPONSES.CREATE_COHORT, ) - return APIResponse.success( + // Send response to the client + const apiResponse = APIResponse.success( res, apiId, resBody, HttpStatus.CREATED, API_RESPONSES.CREATE_COHORT ); + + // Publish cohort created event to Kafka asynchronously - after response is sent to client + this.publishCohortEvent('created', response.cohortId, academicYearId, apiId) + .catch(error => LoggerUtil.error( + `Failed to publish cohort created event to Kafka`, + `Error: ${error.message}`, + apiId + )); + + return apiResponse; } catch (error) { LoggerUtil.error( `${API_RESPONSES.SERVER_ERROR}`, @@ -612,13 +625,25 @@ export class PostgresCohortService { LoggerUtil.log( API_RESPONSES.COHORT_UPDATED_SUCCESSFULLY, ) - return APIResponse.success( + + // Send response to the client + const apiResponse = APIResponse.success( res, apiId, response?.affected, HttpStatus.OK, API_RESPONSES.COHORT_UPDATED_SUCCESSFULLY ); + + // Publish cohort updated event to Kafka asynchronously - after response is sent to client + this.publishCohortEvent('updated', cohortId, null, apiId) + .catch(error => LoggerUtil.error( + `Failed to publish cohort updated event to Kafka`, + `Error: ${error.message}`, + apiId + )); + + return apiResponse; } else { return APIResponse.error( res, @@ -833,7 +858,7 @@ export class PostgresCohortService { if (Object.keys(searchCustomFields).length > 0) { const context = "COHORT"; getCohortIdUsingCustomFields = - await this.fieldsService.filterUserUsingCustomFields( + await this.fieldsService.filterUserUsingCustomFieldsOptimized( context, searchCustomFields ); @@ -949,13 +974,24 @@ export class PostgresCohortService { await this.cohortMembersRepository.delete({ cohortId: cohortId }); await this.fieldValuesRepository.delete({ itemId: cohortId }); - return APIResponse.success( + // Send response to the client + const apiResponse = APIResponse.success( response, apiId, affectedrows[1], HttpStatus.OK, "Cohort Deleted Successfully." ); + + // Publish cohort deleted event to Kafka asynchronously - after response is sent to client + this.publishCohortEvent('deleted', cohortId, null, apiId) + .catch(error => LoggerUtil.error( + `Failed to publish cohort deleted event to Kafka`, + `Error: ${error.message}`, + apiId + )); + + return apiResponse; } else { return APIResponse.error( response, @@ -1033,13 +1069,14 @@ export class PostgresCohortService { return hierarchy; } - public async getCohortDetailsByIds(ids: string[]) { - return await this.cohortRepository.find({ - where: { - cohortId: In(ids), - }, - select: ["cohortId", "name", "parentId", "type", "status"], - }); + public async getCohortDetailsByIds(ids: string[], academicYearId) { + return await this.cohortRepository + .createQueryBuilder('cohort') + .innerJoin('CohortAcademicYear', 'cay', 'cohort.cohortId = cay.cohortId') + .where('cohort.cohortId IN (:...ids)', { ids }) + .andWhere('cay.academicYearId = :academicYearId', { academicYearId }) + .select(['cohort.cohortId', 'cohort.name', 'cohort.parentId', 'cohort.type', 'cohort.status']) + .getMany(); } public async automaticMemberCohortHierarchy(requiredData, academicYearId) { @@ -1060,7 +1097,7 @@ export class PostgresCohortService { throw new Error("No cohort IDs found for the given fieldId and value."); } - const existingCohortIds = await this.getCohortDetailsByIds(cohortIds); + const existingCohortIds = await this.getCohortDetailsByIds(cohortIds,academicYearId); return existingCohortIds; } @@ -1130,4 +1167,93 @@ export class PostgresCohortService { ); } } + + /** + * Publish cohort events to Kafka + * @param eventType Type of event (created, updated, deleted) + * @param cohortId Cohort ID for whom the event is published + * @param apiId API ID for logging + */ + private async publishCohortEvent( + eventType: 'created' | 'updated' | 'deleted', + cohortId: string, + academicYearId: string | null, + apiId: string + ): Promise { + try { + // For delete events, we may want to include just basic information since the cohort might already be removed + let cohortData: any; + + if (eventType === 'deleted') { + cohortData = { + cohortId: cohortId, + deletedAt: new Date().toISOString() + }; + } else { + // For create and update, fetch complete data from DB + try { + // Get basic cohort information + const cohort = await this.cohortRepository.findOne({ + where: { cohortId: cohortId }, + select: [ + "cohortId", + "name", + "type", + "status", + "parentId", + "tenantId", + "createdAt", + "updatedAt", + "createdBy", + "updatedBy" + ] + }); + + if (!cohort) { + LoggerUtil.error(`Failed to fetch cohort data for Kafka event`, `Cohort with ID ${cohortId} not found`); + cohortData = { cohortId }; + } else { + // Get custom fields for the cohort + let customFields = []; + try { + customFields = await this.fieldsService.getCustomFieldDetails(cohortId, 'Cohort'); + } catch (customFieldError) { + LoggerUtil.error( + `Failed to fetch custom fields for Kafka event`, + `Error: ${customFieldError.message}`, + apiId + ); + // Don't fail the entire operation if custom fields fetching fails + customFields = []; + } + + // Build the cohort data object + cohortData = { + ...cohort, + ...(academicYearId && { academicYearId }), + customFields: customFields || [], + eventTimestamp: new Date().toISOString() + }; + } + } catch (error) { + LoggerUtil.error( + `Failed to fetch cohort data for Kafka event`, + `Error: ${error.message}` + ); + // Return at least the cohortId if we can't fetch complete data + cohortData = { cohortId }; + } + } + + await this.kafkaService.publishCohortEvent(eventType, cohortData, cohortId); + LoggerUtil.log(`Cohort ${eventType} event published to Kafka for cohort ${cohortId}`, apiId); + } catch (error) { + LoggerUtil.error( + `Failed to publish cohort ${eventType} event to Kafka`, + `Error: ${error.message}`, + apiId + ); + // Don't throw the error to avoid affecting the main operation + } + } } diff --git a/src/adapters/postgres/cohortMembers-adapter.ts b/src/adapters/postgres/cohortMembers-adapter.ts index 58bdff91..7512e8e8 100644 --- a/src/adapters/postgres/cohortMembers-adapter.ts +++ b/src/adapters/postgres/cohortMembers-adapter.ts @@ -622,7 +622,7 @@ export class PostgresCohortMembersService { whereCase += where.map(processCondition).join(" AND "); } - let query = `SELECT U."userId", U."username", "firstName", "middleName", "lastName", R."name" AS role, U."mobile",U."deviceId", + let query = `SELECT U."userId", U."enrollmentId", U."username", "firstName", "middleName", "lastName", R."name" AS role, U."mobile",U."deviceId", CM."status", CM."statusReason",CM."cohortMembershipId",CM."status",CM."createdAt", CM."updatedAt",U."createdBy",U."updatedBy", COUNT(*) OVER() AS total_count FROM public."CohortMembers" CM INNER JOIN public."Users" U ON CM."userId" = U."userId" diff --git a/src/adapters/postgres/fields-adapter.ts b/src/adapters/postgres/fields-adapter.ts index 5879cd15..2b0bf050 100644 --- a/src/adapters/postgres/fields-adapter.ts +++ b/src/adapters/postgres/fields-adapter.ts @@ -1194,7 +1194,7 @@ export class PostgresFieldsService implements IServicelocatorfields { } = fieldsOptionsSearchDto; offset = offset || 0; - limit = limit || 200; + limit = limit || 1000; const condition: any = { name: fieldName, @@ -1466,19 +1466,21 @@ export class PostgresFieldsService implements IServicelocatorfields { const orderCond = order || ""; const offsetCond = offset ? `offset ${offset}` : ""; const limitCond = limit ? `limit ${limit}` : ""; - let whereCond = `WHERE `; - whereCond = whereClause ? (whereCond += `${whereClause}`) : ""; + const conditions = []; + + if (whereClause) { + conditions.push(`${whereClause}`); + } + + // Apply default filter to fetch only active records + conditions.push(`is_active=1`); if (optionSelected) { - if (whereCond) { - whereCond += `AND "${tableName}_name" ILike '%${optionSelected}%'`; - } else { - whereCond += `WHERE "${tableName}_name" ILike '%${optionSelected}%'`; - } - } else { - whereCond += ""; + conditions.push(`"${tableName}_name" ILike '%${optionSelected}%'`); } + const whereCond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const query = `SELECT *,COUNT(*) OVER() AS total_count FROM public."${tableName}" ${whereCond} ${orderCond} ${offsetCond} ${limitCond}`; const result = await this.fieldsRepository.query(query); @@ -1536,6 +1538,71 @@ export class PostgresFieldsService implements IServicelocatorfields { return result; } + // OPTIMIZED VERSION - Much faster alternative to avoid JSON aggregation + async filterUserUsingCustomFieldsOptimized(context: string, stateDistBlockData: any) { + let joinCond = ""; + let targetTable = ""; + + if (context === "COHORT") { + joinCond = `JOIN "Cohort" u ON fv."itemId" = u."cohortId"`; + targetTable = "Cohort"; + } else if (context === "USERS") { + joinCond = `JOIN "Users" u ON fv."itemId" = u."userId"`; + targetTable = "Users"; + } else { + // Generic case - no specific table join + targetTable = "FieldValues"; + } + + // Build EXISTS conditions for each field filter + const conditions = []; + let paramIndex = 1; + const queryParams = []; + + for (const [fieldName, fieldValues] of Object.entries(stateDistBlockData)) { + const values = Array.isArray(fieldValues) ? fieldValues : [fieldValues]; + + // Create placeholders for parameterized query + const valuePlaceholders = values.map(() => `$${paramIndex++}`); + queryParams.push(...values); + + const condition = ` + EXISTS ( + SELECT 1 + FROM "FieldValues" fv_inner + JOIN "Fields" f_inner ON fv_inner."fieldId" = f_inner."fieldId" + WHERE fv_inner."itemId" = ${context === 'COHORT' ? 'c."cohortId"' : 'u."userId"'} + AND f_inner."name" = $${paramIndex} + AND (f_inner.context IN($${paramIndex + 1}, 'NULL', 'null', '') OR f_inner.context IS NULL) + AND fv_inner."value" && ARRAY[${valuePlaceholders.join(',')}] + )`; + + queryParams.push(fieldName, context); + paramIndex += 2; + conditions.push(condition); + } + + let query; + if (context === "COHORT") { + query = ` + SELECT DISTINCT c."cohortId" as "itemId" + FROM "Cohort" c + WHERE ${conditions.join(' AND ')}`; + } else if (context === "USERS") { + query = ` + SELECT DISTINCT u."userId" as "itemId" + FROM "Users" u + WHERE ${conditions.join(' AND ')}`; + } else { + // Fallback to original logic for unknown context + return this.filterUserUsingCustomFields(context, stateDistBlockData); + } + + const queryData = await this.fieldsValuesRepository.query(query, queryParams); + const result = queryData.length > 0 ? queryData.map((item) => item.itemId) : null; + return result; + } + async filterUserUsingCustomFields(context: string, stateDistBlockData: any) { const searchKey = []; let whereCondition = ` WHERE `; @@ -1690,6 +1757,24 @@ export class PostgresFieldsService implements IServicelocatorfields { return result; } + async updateUserCustomFields(itemId, data, fieldAttributesAndParams) { + // Ensure value is stored as an array + if (!Array.isArray(data.value)) { + data.value = [data.value]; + } + + const result = await this.fieldsValuesRepository.insert({ + itemId, + fieldId: data.fieldId, + value: data.value, + }); + + return { + ...result, + correctValue: true, + }; + } + validateFieldValue(field: any, value: any) { try { const fieldInstance = FieldFactory.createField( diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index 63704d09..9e295e66 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -10,6 +10,7 @@ import { createUserInKeyCloak, updateUserInKeyCloak, checkIfUsernameExistsInKeycloak, + updateUserEnabledStatusInKeycloak, checkIfEmailExistsInKeycloak, } from "../../common/utils/keycloak.adapter.util"; import { ErrorResponse } from "src/error-response"; @@ -67,6 +68,7 @@ export class PostgresUserService implements IServicelocator { private readonly otpDigits: number; private readonly smsKey: string; private readonly dataSource: DataSource; + private readonly msg91TemplateKey: string; constructor( // private axiosInstance: AxiosInstance, @@ -105,6 +107,7 @@ export class PostgresUserService implements IServicelocator { this.otpExpiry = this.configService.get('OTP_EXPIRY') || 10; // default: 10 minutes this.otpDigits = this.configService.get('OTP_DIGITS') || 6; this.smsKey = this.configService.get('SMS_KEY'); + this.msg91TemplateKey = this.configService.get('MSG91_TEMPLATE_KEY'); this.dataSource = dataSource; // Store dataSource in class property } @@ -356,7 +359,7 @@ export class PostgresUserService implements IServicelocator { ) { const apiId = APIID.USER_LIST; try { - const findData = await this.findAllUserDetails(userSearchDto); + const findData = await this.findAllUserDetails(userSearchDto, tenantId); if (findData === false) { LoggerUtil.error( @@ -399,7 +402,7 @@ export class PostgresUserService implements IServicelocator { } - async findAllUserDetails(userSearchDto) { + async findAllUserDetails(userSearchDto, tenantId?: string) { let { limit, offset, filters, exclude, sort } = userSearchDto; let excludeCohortIdes; let excludeUserIdes; @@ -425,7 +428,7 @@ export class PostgresUserService implements IServicelocator { if (filters && Object.keys(filters).length > 0) { //Fwtch all core fields let coreFields = await this.getCoreColumnNames(); - const allCoreField = [...coreFields, 'fromDate', 'toDate', 'role', 'tenantId']; + const allCoreField = [...coreFields, 'fromDate', 'toDate', 'role', 'tenantId', 'name']; for (const [key, value] of Object.entries(filters)) { //Check request filter are proesent on core file or cutom fields @@ -435,6 +438,7 @@ export class PostgresUserService implements IServicelocator { } switch (key) { case "firstName": + case "name": whereCondition += ` U."${key}" ILIKE '%${value}%'`; index++; break; @@ -511,7 +515,7 @@ export class PostgresUserService implements IServicelocator { const context = "USERS"; getUserIdUsingCustomFields = - await this.fieldsService.filterUserUsingCustomFields( + await this.fieldsService.filterUserUsingCustomFieldsOptimized( context, searchCustomFields ); @@ -552,8 +556,20 @@ export class PostgresUserService implements IServicelocator { whereCondition = ""; } + // Apply tenant filtering conditionally if tenantId is provided from headers + if (tenantId && tenantId.trim() !== '') { + if (index === 0 && whereCondition === "") { + whereCondition = `WHERE UTM."tenantId" = '${tenantId}'`; + } else { + whereCondition += ` AND UTM."tenantId" = '${tenantId}'`; + } + LoggerUtil.log(`Applying tenant filter for tenantId: ${tenantId}`, APIID.USER_LIST); + } else { + LoggerUtil.warn(`No tenantId provided - returning users from all tenants`, APIID.USER_LIST); + } + //Get user core fields data - const query = `SELECT U."userId", U."username",U."email", U."firstName",UTM."tenantId", U."middleName", U."lastName", U."gender", U."dob", R."name" AS role, U."mobile", U."createdBy",U."updatedBy", U."createdAt", U."updatedAt", U."status", COUNT(*) OVER() AS total_count + const query = `SELECT U."userId",U."enrollmentId", U."username",U."email", U."firstName", U."name",UTM."tenantId", U."middleName", U."lastName", U."gender", U."dob", R."name" AS role, U."mobile", U."createdBy",U."updatedBy", U."createdAt", U."updatedAt", U."status", COUNT(*) OVER() AS total_count FROM public."Users" U LEFT JOIN public."CohortMembers" CM ON CM."userId" = U."userId" @@ -746,8 +762,10 @@ export class PostgresUserService implements IServicelocator { where: whereClause, select: [ "userId", + "enrollmentId", "username", "firstName", + "name", "middleName", "lastName", "gender", @@ -755,7 +773,10 @@ export class PostgresUserService implements IServicelocator { "mobile", "email", "temporaryPassword", + "createdAt", + "updatedAt", "createdBy", + "updatedBy", "deviceId", ], }); @@ -768,7 +789,7 @@ export class PostgresUserService implements IServicelocator { } const tenantData = tenantId ? tenentDetails.filter((item) => item.tenantId === tenantId) - : tenentDetails; + : tenentDetails; userDetails["tenantData"] = tenantData; return userDetails; @@ -784,6 +805,8 @@ export class PostgresUserService implements IServicelocator { T."collectionFramework", T."channelId", T.name AS tenantName, + T.params, + T."type", UTM."Id" AS userTenantMappingId FROM public."UserTenantMapping" UTM @@ -795,7 +818,7 @@ export class PostgresUserService implements IServicelocator { UTM."userId" = $1 ORDER BY T."tenantId", UTM."Id";`; - + const result = await this.usersRepository.query(query, [userId]); const combinedResult = []; const roleArray = []; @@ -804,7 +827,7 @@ export class PostgresUserService implements IServicelocator { userId, data.tenantId ); - + if (roleData.length > 0) { roleArray.push(roleData[0].roleid); const roleId = roleData[0].roleid; @@ -822,8 +845,10 @@ export class PostgresUserService implements IServicelocator { collectionFramework: data.collectionFramework, channelId: data.channelId, userTenantMappingId: data.usertenantmappingid, + params: data.params, roleId: roleId, roleName: roleName, + tenantType: data.type, // privileges: privileges, }); } @@ -864,7 +889,6 @@ export class PostgresUserService implements IServicelocator { } } - const { username, firstName, lastName, email } = userDto.userData; const userId = userDto.userId; const keycloakReqBody = { username, firstName, lastName, userId, email }; @@ -920,6 +944,20 @@ export class PostgresUserService implements IServicelocator { userDto?.userId ); + + // Synchronize user status with Keycloak + if (userDto.userData?.status) { + const isUserActive = userDto.userData.status === 'active'; + + // Async Keycloak status synchronization - non-blocking + this.syncUserStatusWithKeycloak(userDto.userId, isUserActive, apiId) + .catch(error => LoggerUtil.error( + 'Keycloak user status sync failed', + `Error: ${error.message}`, + apiId + )); + } + if (userDto?.customFields?.length > 0) { const getFieldsAttributes = await this.fieldsService.getEditableFieldsAttributes(userDto.userData.tenantId); @@ -1028,7 +1066,7 @@ export class PostgresUserService implements IServicelocator { `Error: ${error.message}`, apiId )); - + return apiResponse; } catch (e) { LoggerUtil.error( @@ -1118,6 +1156,40 @@ export class PostgresUserService implements IServicelocator { } } + + private async syncUserStatusWithKeycloak(userId: string, isActive: boolean, apiId: string): Promise { + try { + const keycloakResponse = await getKeycloakAdminToken(); + const token = keycloakResponse.data.access_token; + + const result = await updateUserEnabledStatusInKeycloak( + { userId, enabled: isActive }, + token + ); + + if (result.success) { + LoggerUtil.log( + `Keycloak user status synchronized successfully: ${isActive ? 'enabled' : 'disabled'}`, + apiId, + userId + ); + } else { + LoggerUtil.error( + 'Keycloak user status synchronization failed', + `Status: ${result.statusCode}, Message: ${result.message}`, + apiId + ); + } + } catch (error) { + LoggerUtil.error( + 'Keycloak user status synchronization error', + `Failed to sync user status: ${error.message}`, + apiId + ); + throw error; + } + } + async loginDeviceIdAction(userDeviceId: string, userId: string, existingDeviceId: string[]): Promise { let deviceIds = existingDeviceId || []; // Check if the device ID already exists @@ -1170,15 +1242,35 @@ export class PostgresUserService implements IServicelocator { response: Response ) { const apiId = APIID.USER_CREATE; - // It is considered that if user is not present in keycloak it is not present in database as well + const startTime = Date.now(); + const stepTimings = {}; + + const userContext = { + username: userCreateDto?.username, + email: userCreateDto?.email, + firstName: userCreateDto?.firstName, + lastName: userCreateDto?.lastName + }; + + // Log user creation attempt with context + LoggerUtil.log( + `User creation attempt started for ${userContext.username}`, + apiId, + userContext.username + ); try { + // Step 1: Extract user info from JWT token + const jwtStartTime = Date.now(); if (request.headers.authorization) { const decoded: any = jwt_decode(request.headers.authorization); userCreateDto.createdBy = decoded?.sub; userCreateDto.updatedBy = decoded?.sub; } + stepTimings['jwt_extraction'] = Date.now() - jwtStartTime; + // Step 2: Validate custom fields + const customFieldStartTime = Date.now(); let customFieldError; if (userCreateDto.customFields && userCreateDto.customFields.length > 0) { customFieldError = await this.validateCustomField( @@ -1197,8 +1289,10 @@ export class PostgresUserService implements IServicelocator { ); } } + stepTimings['custom_field_validation'] = Date.now() - customFieldStartTime; - // check and validate all fields + // Step 3: Validate request body and roles + const validationStartTime = Date.now(); const validatedRoles: any = await this.validateRequestBody( userCreateDto, academicYearId @@ -1209,6 +1303,12 @@ export class PostgresUserService implements IServicelocator { Array.isArray(validatedRoles) && validatedRoles.some((item) => item?.code === undefined) ) { + LoggerUtil.error( + `Role validation failed for ${userContext.username}`, + validatedRoles.join("; "), + apiId, + userContext.username + ); return APIResponse.error( response, apiId, @@ -1217,13 +1317,17 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } + stepTimings['request_validation'] = Date.now() - validationStartTime; - if(userCreateDto.email){ - let sendOtp = await this.sendOtpOnMail(userCreateDto.email, userCreateDto.firstName, 'signup'); - } - - //Validaion if try to assign on cohort and automaticMember + // Step 4: Validate automatic member vs cohort assignment + const businessLogicStartTime = Date.now(); if (userCreateDto.automaticMember?.value === true && userCreateDto.tenantCohortRoleMapping?.[0]?.cohortIds?.length > 0) { + LoggerUtil.error( + `Invalid operation for ${userContext.username}: Cannot assign automatic member with cohort`, + `User cannot be assigned as automatic member while also being assigned to a center`, + apiId, + userContext.username + ); return APIResponse.error( response, apiId, @@ -1232,19 +1336,26 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } + stepTimings['business_logic_validation'] = Date.now() - businessLogicStartTime; + // Step 5: Prepare username and check Keycloak + const keycloakCheckStartTime = Date.now(); userCreateDto.username = userCreateDto.username.toLocaleLowerCase(); const userSchema = new UserCreateDto(userCreateDto); - let resKeycloak; - const keycloakResponse = await getKeycloakAdminToken(); const token = keycloakResponse.data.access_token; const checkUserinKeyCloakandDb = await this.checkUserinKeyCloakandDb( userCreateDto ); - // let checkUserinDb = await this.checkUserinKeyCloakandDb(userCreateDto.username); + if (checkUserinKeyCloakandDb) { + LoggerUtil.error( + `User ${userContext.username} already exists`, + `User with username ${userCreateDto.username} or email ${userCreateDto.email} already exists`, + apiId, + userContext.username + ); return APIResponse.error( response, apiId, @@ -1253,13 +1364,46 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } + stepTimings['keycloak_user_check'] = Date.now() - keycloakCheckStartTime; + + // Step 6: Create user in Keycloak + const keycloakCreateStartTime = Date.now(); + LoggerUtil.log( + `Creating user ${userContext.username} in Keycloak`, + apiId, + userContext.username + ); + + const resKeycloak = await createUserInKeyCloak(userSchema, token, validatedRoles[0]?.title) - // Multi tenant for roles is not currently supported in keycloak - resKeycloak = await createUserInKeyCloak(userSchema, token, validatedRoles[0]?.title) + // Capture Keycloak creation timing immediately after the call + stepTimings['keycloak_user_creation'] = Date.now() - keycloakCreateStartTime; + + // Handle the case where createUserInKeyCloak returns a string (error) + if (typeof resKeycloak === 'string') { + LoggerUtil.error( + `Keycloak user creation failed for ${userContext.username}`, + resKeycloak, + apiId, + userContext.username + ); + return APIResponse.error( + response, + apiId, + API_RESPONSES.SERVER_ERROR, + resKeycloak, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } if (resKeycloak.statusCode !== 201) { if (resKeycloak.statusCode === 409) { - LoggerUtil.log(API_RESPONSES.EMAIL_EXIST, apiId); + LoggerUtil.error( + `Email already exists in Keycloak for ${userContext.username}`, + `${resKeycloak.message} ${resKeycloak.email}`, + apiId, + userContext.username + ); return APIResponse.error( response, @@ -1269,7 +1413,12 @@ export class PostgresUserService implements IServicelocator { HttpStatus.CONFLICT ); } else { - LoggerUtil.log(API_RESPONSES.SERVER_ERROR, apiId); + LoggerUtil.error( + `Keycloak user creation failed for ${userContext.username}`, + `${resKeycloak.message}`, + apiId, + userContext.username + ); return APIResponse.error( response, apiId, @@ -1280,12 +1429,21 @@ export class PostgresUserService implements IServicelocator { } } - LoggerUtil.log(API_RESPONSES.USER_CREATE_KEYCLOAK, apiId); - + LoggerUtil.log( + `User ${userContext.username} created successfully in Keycloak`, + apiId, + userContext.username + ); userCreateDto.userId = resKeycloak.userId; - // if cohort given then check for academic year + // Step 7: Create user in database + const dbCreateStartTime = Date.now(); + LoggerUtil.log( + `Creating user ${userContext.username} in database`, + apiId, + userContext.username + ); const result = await this.createUserInDatabase( request, @@ -1293,9 +1451,16 @@ export class PostgresUserService implements IServicelocator { academicYearId, response ); + stepTimings['database_user_creation'] = Date.now() - dbCreateStartTime; - LoggerUtil.log(API_RESPONSES.USER_CREATE_IN_DB, apiId); + LoggerUtil.log( + `User ${userContext.username} created successfully in database`, + apiId, + userContext.username + ); + // Step 8: Handle custom fields + const customFieldsStartTime = Date.now(); const createFailures = []; if ( result && @@ -1332,25 +1497,40 @@ export class PostgresUserService implements IServicelocator { value: fieldValues["value"], }; - const res = await this.fieldsService.updateCustomFields( + const res = await this.fieldsService.updateUserCustomFields( userId, fieldData, customFieldAttributes[fieldData.fieldId] ); - if (res.correctValue) { - if (!result["customFields"]) result["customFields"] = []; - result["customFields"].push(res); - } else { - createFailures.push( - `${fieldData.fieldId}: ${res?.valueIssue} - ${res.fieldName}` - ); - } + // if (res.correctValue) { + // if (!result["customFields"]) result["customFields"] = []; + // result["customFields"].push(res); + // } else { + // createFailures.push( + // `${fieldData.fieldId}: ${res?.valueIssue} - ${res.fieldName}` + // ); + // } } } } - LoggerUtil.log(API_RESPONSES.USER_CREATE_SUCCESSFULLY, apiId); - + stepTimings['custom_fields_processing'] = Date.now() - customFieldsStartTime; + + // Step 9: Log performance metrics + const totalTime = Date.now() - startTime; + LoggerUtil.log( + `User ${userContext.username} created successfully with ID: ${result.userId}`, + apiId, + userContext.username + ); + + // Log performance breakdown + LoggerUtil.log( + `Performance breakdown for user creation (${userContext.username}): Total: ${totalTime}ms | JWT: ${stepTimings['jwt_extraction']}ms | Custom Fields Validation: ${stepTimings['custom_field_validation']}ms | Request Validation: ${stepTimings['request_validation']}ms | Business Logic: ${stepTimings['business_logic_validation']}ms | Keycloak Check: ${stepTimings['keycloak_user_check']}ms | Keycloak Creation: ${stepTimings['keycloak_user_creation']}ms | Database Creation: ${stepTimings['database_user_creation']}ms | Custom Fields Processing: ${stepTimings['custom_fields_processing']}ms`, + apiId, + userContext.username + ); + // Send response to the client APIResponse.success( response, @@ -1359,20 +1539,22 @@ export class PostgresUserService implements IServicelocator { HttpStatus.CREATED, API_RESPONSES.USER_CREATE_SUCCESSFULLY ); - + // Produce user created event to Kafka asynchronously - after response is sent to client this.publishUserEvent('created', result.userId, apiId) .catch(error => LoggerUtil.error( - `Failed to publish user created event to Kafka`, + `Failed to publish user created event to Kafka for ${userContext.username}`, `Error: ${error.message}`, - apiId + apiId, + userContext.username )); } catch (e) { LoggerUtil.error( `${API_RESPONSES.SERVER_ERROR}: ${request.url}`, `Error: ${e.message}`, - apiId + apiId, + userContext.username ); const errorMessage = e.message || API_RESPONSES.INTERNAL_SERVER_ERROR; return APIResponse.error( @@ -1949,7 +2131,7 @@ export class PostgresUserService implements IServicelocator { } const fieldAttributes = getFieldDetails?.fieldAttributes || {}; // getFieldDetails["fieldAttributes"] = fieldAttributes[tenantId] || fieldAttributes["default"]; - getFieldDetails["fieldAttributes"] = fieldAttributes; + getFieldDetails["fieldAttributes"] = fieldAttributes; if ( (getFieldDetails.type == "checkbox" || @@ -2046,6 +2228,13 @@ export class PostgresUserService implements IServicelocator { .map((fieldValue) => fieldValue.fieldId); if (invalidFieldIds.length > 0) { + // Log the invalid field validation error with role context + LoggerUtil.error( + `Invalid custom fields provided for role`, + `Role: ${contextType || 'Unknown'}, Invalid Field IDs: ${invalidFieldIds.join(", ")}, User: ${userCreateDto.username || 'Unknown'}`, + apiId, + userCreateDto.username + ); return `The following fields are not valid for this user: ${invalidFieldIds.join( ", " )}.`; @@ -2178,14 +2367,47 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } - // Step 1: Prepare data for OTP generation and send on Mobile - const { notificationPayload, hash, expires } = await this.sendOTPOnMobile(mobile, reason); + // Step 1: Prepare data for OTP generation and send based on channel + let notificationPayload: any; + let hash: string; + let expires: number; + + if (reason === 'signup' || reason === 'login') { + const channelOverride = ((body as any)?.channel || '').toLowerCase(); + if (channelOverride === 'sms') { + // Send via SMS ONLY for signup/login without triggering WhatsApp + const mobileWithCode = this.formatMobileNumber(mobile); + const otp = this.authUtils.generateOtp(this.otpDigits).toString(); + const generated = this.generateOtpHash(mobileWithCode, otp, reason); + hash = generated.hash; + expires = generated.expires; + const replacements = { + "{OTP}": otp, + "{otpExpiry}": generated.expiresInMinutes + }; + notificationPayload = await this.smsNotification("OTP", "SEND_OTP", replacements, [mobile]); + } else { + // Default: WhatsApp ONLY for signup/login + const otp = this.authUtils.generateOtp(this.otpDigits).toString(); + const waResult = await this.sendOtpOnWhatsApp(mobile, otp, reason); + notificationPayload = waResult.notificationPayload; + hash = waResult.hash; + expires = waResult.expires; + } + } else { + // Default (e.g., forgot) uses existing SMS path + const smsResult = await this.sendOTPOnMobile(mobile, reason); + notificationPayload = smsResult.notificationPayload; + hash = smsResult.hash; + expires = smsResult.expires; + } + // Step 2: Send success response const result = { data: { message: `OTP sent to ${mobile}`, hash: `${hash}.${expires}`, - sendStatus: notificationPayload.result?.sms?.data[0] + sendStatus: notificationPayload?.result?.whatsapp?.data?.[0] || notificationPayload?.result?.sms?.data?.[0] // sid: message.sid, // Twilio Message SID } }; @@ -2225,18 +2447,115 @@ export class PostgresUserService implements IServicelocator { }; // Step 2:send SMS notification const notificationPayload = await this.smsNotification("OTP", "SEND_OTP", replacements, [mobile]); + // Step 3: For signup/login, also send via WhatsApp only (do not trigger other channels) + if (reason === 'signup' || reason === 'login') { + try { + await this.sendOtpOnWhatsApp(mobile, otp, reason); + } catch (waErr: any) { + LoggerUtil.warn(`WhatsApp OTP send failed: ${waErr?.message || waErr}`, APIID.SEND_OTP); + } + } return { notificationPayload, hash, expires, expiresInMinutes }; } catch (error) { throw new Error(`Failed to send OTP: ${error.message}`); } } + + async sendOtpOnWhatsApp(whatsapp: string, otp: string, reason: string) { + try { + const formattedWhatsapp = this.formatMobileNumber(whatsapp); + const { hash, expires, expiresInMinutes } = this.generateOtpHash(formattedWhatsapp, otp, reason); + const notificationPayload = await this.whatsappNotificationRaw(whatsapp, otp, reason); + return { notificationPayload, hash, expires, expiresInMinutes }; + } + catch (error) { + throw new Error(`Failed to send OTP via WhatsApp: ${error.message}`); + } + } + + async whatsappNotificationRaw(whatsapp: string, otp: string, reason: string) { + try { + const formattedWhatsapp = this.formatMobileNumber(whatsapp); + const templateId = this.configService.get("WHATSAPP_TEMPLATE_ID"); + const apiKey = this.configService.get("WHATSAPP_GUPSHUP_API_KEY"); + const gupshupSource = this.configService.get("WHATSAPP_GUPSHUP_SOURCE"); + + if (!templateId || !apiKey || !gupshupSource) { + LoggerUtil.error( + "WhatsApp environment variables not configured", + "WhatsApp OTP sending is disabled. Please configure WHATSAPP_TEMPLATE_ID, WHATSAPP_GUPSHUP_API_KEY, and WHATSAPP_GUPSHUP_SOURCE", + "WHATSAPP_CONFIG" + ); + return { + result: { + whatsapp: { + data: [{ status: "skipped", message: "WhatsApp not configured" }], + }, + }, + }; + } + + const payload = { + whatsapp: { + to: [formattedWhatsapp], + templateId: templateId, + templateParams: [otp], + gupshupSource: gupshupSource, + gupshupApiKey: apiKey, + }, + }; + + const mailSend = await this.notificationRequest.sendRawNotification(payload); + if (mailSend?.result?.whatsapp?.errors && mailSend.result.whatsapp.errors.length > 0) { + const errorMessages = mailSend.result.whatsapp.errors.map((error: { error: string; }) => error.error); + const combinedErrorMessage = errorMessages.join(", "); + throw new Error(`${API_RESPONSES.WHATSAPP_ERROR} :${combinedErrorMessage}`); + } + if (!mailSend || !mailSend.result || !mailSend.result.whatsapp) { + throw new Error("Invalid response from notification service"); + } + return mailSend; + } + catch (error) { + LoggerUtil.error(API_RESPONSES.WHATSAPP_ERROR, error.message); + throw new Error(`${API_RESPONSES.WHATSAPP_NOTIFICATION_ERROR}: ${error.message}`); + } + } + + async whatsappNotification(context: string, key: string, replacements: object, receipients: string[]) { + try { + const notificationPayload = { + isQueue: false, + context: context, + key: key, + replacements: replacements, + whatsapp: { + receipients: receipients.map((recipient) => recipient.toString()), + }, + }; + const result = await this.notificationRequest.sendNotification( + notificationPayload + ); + if (result?.result?.whatsapp?.errors && result.result.whatsapp.errors.length > 0) { + const errorMessages = result.result.whatsapp.errors.map((error: { error: string; }) => error.error); + const combinedErrorMessage = errorMessages.join(", "); + throw new Error(`${API_RESPONSES.WHATSAPP_ERROR} :${combinedErrorMessage}`); + } + return result; + } + catch (error) { + LoggerUtil.error(API_RESPONSES.WHATSAPP_ERROR, error.message); + throw new Error(`${API_RESPONSES.WHATSAPP_NOTIFICATION_ERROR}: ${error.message}`); + } + } + //verify OTP based on reason [signup , forgot] async verifyOtp(body: OtpVerifyDTO, response: Response) { const apiId = APIID.VERIFY_OTP; try { const { mobile, otp, hash, reason, username } = body; - + // Validate required fields for all requests if (!otp || !hash || !reason) { return APIResponse.error( @@ -2247,7 +2566,7 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } - + // Validate hash format const [hashValue, expires] = hash.split('.'); if (!hashValue || !expires || isNaN(parseInt(expires))) { @@ -2259,7 +2578,7 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } - + // Check for OTP expiration if (Date.now() > parseInt(expires)) { return APIResponse.error( @@ -2270,12 +2589,12 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } - + let identifier: string; let resetToken: string | null = null; - + // Process based on reason - if (reason === 'signup') { + if (reason === 'signup' || reason === 'login') { if (!mobile) { return APIResponse.error( response, @@ -2285,8 +2604,9 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } + // Always use 10-digit mobile from body and format to +91 internally for hash match identifier = this.formatMobileNumber(mobile); - } + } else if (reason === 'forgot') { if (!username) { return APIResponse.error( @@ -2297,10 +2617,10 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } - + identifier = this.formatMobileNumber(mobile); const userData = await this.findUserDetails(null, username); - + if (!userData) { return APIResponse.error( response, @@ -2310,19 +2630,19 @@ export class PostgresUserService implements IServicelocator { HttpStatus.NOT_FOUND ); } - + // Generate reset token for forgot password flow const tokenPayload = { sub: userData.userId, email: userData.email, }; - + resetToken = await this.jwtUtil.generateTokenForForgotPassword( tokenPayload, this.jwt_password_reset_expires_In, this.jwt_secret ); - } + } else { return APIResponse.error( response, @@ -2332,19 +2652,17 @@ export class PostgresUserService implements IServicelocator { HttpStatus.BAD_REQUEST ); } - + // Verify OTP hash const data = `${identifier}.${otp}.${reason}.${expires}`; - console.log(data); const calculatedHash = this.authUtils.calculateHash(data, this.smsKey); - console.log(calculatedHash, hashValue); if (calculatedHash === hashValue) { // For forgot password flow, include the reset token in response const responseData = { success: true }; if (reason === 'forgot' && resetToken) { responseData['token'] = resetToken; } - + return APIResponse.success( response, apiId, @@ -2367,7 +2685,7 @@ export class PostgresUserService implements IServicelocator { `Error during OTP verification: ${error.message}`, apiId ); - + return APIResponse.error( response, apiId, @@ -2525,8 +2843,8 @@ export class PostgresUserService implements IServicelocator { receipients: emailReceipt, }, }; - console.log("notificationPayload",notificationPayload); - + // console.log("notificationPayload",notificationPayload); + const mailSend = await this.notificationRequest.sendNotification( notificationPayload ); @@ -2562,7 +2880,7 @@ export class PostgresUserService implements IServicelocator { "{eventName}": "Shiksha Graha OTP", "{action}": "register" }; - console.log("hii",replacements,email) + // console.log("hii",replacements,email) // Step 4: Send email notification const notificationPayload = await this.sendEmailNotification("OTP", "SendOtpOnMail", replacements, [email]); @@ -2586,7 +2904,7 @@ export class PostgresUserService implements IServicelocator { if (filters && Object.keys(filters).length > 0) { Object.entries(filters).forEach(([key, value]) => { if (value !== undefined && value !== null) { - if (key === 'firstName' || key === 'middleName' || key === 'lastName') { + if (key === 'firstName' || key === 'name' || key === 'middleName' || key === 'lastName') { const sanitizedValue = this.sanitizeInput(value); whereClause[key] = ILike(`%${sanitizedValue}%`); } else { @@ -2598,7 +2916,7 @@ export class PostgresUserService implements IServicelocator { // Use the dynamic where clause to fetch matching data const findData = await this.usersRepository.find({ where: whereClause, - select: ['username', 'firstName', 'middleName', 'lastName','mobile'], // Select only these fields + select: ['username', 'firstName', 'name', 'middleName', 'lastName', 'mobile'], // Select only these fields }); if (findData.length === 0) { @@ -2723,7 +3041,7 @@ export class PostgresUserService implements IServicelocator { try { // For delete events, we may want to include just basic information since the user might already be removed let userData: any; - + if (eventType === 'deleted') { userData = { userId: userId, @@ -2739,6 +3057,7 @@ export class PostgresUserService implements IServicelocator { "userId", "username", "firstName", + "name", "middleName", "lastName", "gender", @@ -2757,15 +3076,79 @@ export class PostgresUserService implements IServicelocator { } else { // Get tenant and role information const tenantRoleData = await this.userTenantRoleData(userId); - + // Get custom fields if any const customFields = await this.fieldsService.getCustomFieldDetails(userId, 'Users'); + + // Get cohort information for the user + let cohorts = []; + try { + // Enhanced query to fetch batch, parent cohort, and academic year details + const cohortQuery = ` + WITH BatchData AS ( + SELECT + cm."cohortId" as "batchId", + cm."createdAt" as "joinedAt", + cm."status" as "cohortMemberStatus", + batch."name" as "batchName", + batch."type" as "batchType", + batch."status" as "batchStatus", + batch."tenantId", + batch."parentId" as "cohortId" + FROM public."CohortMembers" cm + JOIN public."Cohort" batch ON cm."cohortId" = batch."cohortId" + WHERE cm."userId" = $1 AND batch."type" = 'BATCH' + ) + SELECT + bd.*, + cohort."name" as "cohortName", + cohort."type" as "cohortType", + cay."academicYearId", + ay."session" as "academicYearSession" + FROM BatchData bd + LEFT JOIN public."Cohort" cohort ON bd."cohortId":: UUID = cohort."cohortId" AND cohort."type" = 'COHORT' + LEFT JOIN public."CohortAcademicYear" cay ON bd."cohortId":: UUID = cay."cohortId" + LEFT JOIN public."AcademicYears" ay ON cay."academicYearId" = ay."id" + `; + + const cohortResults = await this.usersRepository.query(cohortQuery, [userId]); + if (cohortResults && cohortResults.length > 0) { + cohorts = cohortResults.map(result => ({ + // Batch details + batchId: result.batchId, + batchName: result.batchName, + batchStatus: result.batchStatus, + joinedAt: result.joinedAt, + cohortMemberStatus: result.cohortMemberStatus, + tenantId: result.tenantId, + + // Parent Cohort details + cohortId: result.cohortId, + cohortName: result.cohortName, + cohortType: result.cohortType, + + // Academic Year details + academicYearId: result.academicYearId, + academicYearSession: result.academicYearSession + })); + } + } catch (cohortError) { + LoggerUtil.error( + `Failed to fetch cohort data for Kafka event`, + `Error: ${cohortError.message}`, + apiId + ); + // Don't fail the entire operation if cohort fetching fails + cohorts = []; + } + // Build the complete data object userData = { ...user, tenantData: tenantRoleData, customFields: customFields || [], + cohorts: cohorts, eventTimestamp: new Date().toISOString() }; } @@ -2778,7 +3161,6 @@ export class PostgresUserService implements IServicelocator { userData = { userId }; } } - await this.kafkaService.publishUserEvent(eventType, userData, userId); LoggerUtil.log(`User ${eventType} event published to Kafka for user ${userId}`, apiId); } catch (error) { diff --git a/src/app.module.ts b/src/app.module.ts index 95d8047e..ec488b49 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { RolePermissionModule } from "./permissionRbac/rolePermissionMapping/rol import { LocationModule } from "./location/location.module"; import { KafkaModule } from "./kafka/kafka.module"; import kafkaConfig from "./kafka/kafka.config"; +import { HealthController } from "./health.controller"; @Module({ imports: [ RbacModule, @@ -56,7 +57,7 @@ import kafkaConfig from "./kafka/kafka.config"; LocationModule, KafkaModule, ], - controllers: [AppController], + controllers: [AppController, HealthController], providers: [AppService, HttpService], }) export class AppModule { diff --git a/src/cohort/cohort.module.ts b/src/cohort/cohort.module.ts index dd22b7d4..38afa97d 100644 --- a/src/cohort/cohort.module.ts +++ b/src/cohort/cohort.module.ts @@ -22,6 +22,7 @@ import { User } from "src/user/entities/user-entity"; import { Tenants } from "src/userTenantMapping/entities/tenant.entity"; import { AutomaticMember } from "src/automatic-member/entity/automatic-member.entity"; import { AutomaticMemberService } from "src/automatic-member/automatic-member.service"; +import { KafkaService } from "../kafka/kafka.service"; @Module({ @@ -51,7 +52,8 @@ import { AutomaticMemberService } from "src/automatic-member/automatic-member.se CohortAcademicYearService, PostgresAcademicYearService, PostgresCohortMembersService, - AutomaticMemberService + AutomaticMemberService, + KafkaService ], }) export class CohortModule { } diff --git a/src/common/database.module.ts b/src/common/database.module.ts index 1649a7c9..e02a2195 100644 --- a/src/common/database.module.ts +++ b/src/common/database.module.ts @@ -17,6 +17,11 @@ import { User } from "src/user/entities/user-entity"; // User // ], autoLoadEntities: true, + extra: { + max: 20, // Number of connections in the pool (default is 10) + idleTimeoutMillis: 30000, // 30 seconds + connectionTimeoutMillis: 2000, // 2 seconds max to wait for a free connection + }, }), inject: [ConfigService], }), diff --git a/src/common/logger/LoggerUtil.ts b/src/common/logger/LoggerUtil.ts index 1d1bbbd1..55149f95 100644 --- a/src/common/logger/LoggerUtil.ts +++ b/src/common/logger/LoggerUtil.ts @@ -23,13 +23,17 @@ export class LoggerUtil { format: winston.format.combine(winston.format.timestamp(), customFormat), transports: [ new winston.transports.Console(), - new winston.transports.File({ filename: 'error.log', level: 'error' }), - new winston.transports.File({ filename: 'combined.log' }), ], }); } return this.logger; } + + // Method to reset logger (useful for testing or when files are deleted) + static resetLogger() { + this.logger = null; + } + static log( message: string, context?: string, diff --git a/src/common/services/upload-S3.service.ts b/src/common/services/upload-S3.service.ts index 2852786c..e040d37a 100644 --- a/src/common/services/upload-S3.service.ts +++ b/src/common/services/upload-S3.service.ts @@ -65,61 +65,96 @@ export class UploadS3Service { async getPresignedUrl(filename: string, fileType: string, response, foldername?: string): Promise { try { - const allowedFileTypes = [ - '.jpg', '.jpeg', '.png', '.webp', // Images - '.pdf', '.doc', '.docx', // Documents - '.mp4', '.mov', // Videos - '.txt', '.csv' // Text files - ]; - - const mimeTypeMap = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.webp': 'image/webp', - '.pdf': 'application/pdf', - '.doc': 'application/msword', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.mp4': 'video/mp4', - '.mov': 'video/quicktime', - '.txt': 'text/plain', - '.csv': 'text/csv', + // Dynamic MIME type detection based on file extension + const getMimeType = (extension: string): string => { + const mimeTypes: { [key: string]: string } = { + // Images + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.tiff': 'image/tiff', + '.ico': 'image/x-icon', + + // Documents + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.rtf': 'application/rtf', + + // Text files + '.txt': 'text/plain', + '.csv': 'text/csv', + '.xml': 'text/xml', + '.html': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.json': 'application/json', + + // Videos + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.wmv': 'video/x-ms-wmv', + '.flv': 'video/x-flv', + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', + + // Audio + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.m4a': 'audio/mp4', + '.flac': 'audio/flac', + + // Archives + '.zip': 'application/zip', + '.rar': 'application/vnd.rar', + '.7z': 'application/x-7z-compressed', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + + // Other common formats + '.apk': 'application/vnd.android.package-archive', + '.exe': 'application/octet-stream', + '.dmg': 'application/octet-stream', + '.iso': 'application/octet-stream', + }; + + return mimeTypes[extension.toLowerCase()] || 'application/octet-stream'; }; - // Validate file extension - if (!allowedFileTypes.includes(fileType)) { - return APIResponse.error( - response, - APIID.SIGNED_URL, - API_RESPONSES.BAD_REQUEST, - API_RESPONSES.INVALID_FILE_TYPE, - HttpStatus.BAD_REQUEST, - ); - } - - const contentType = mimeTypeMap[fileType]; + // Get MIME type dynamically + const contentType = getMimeType(fileType); // Construct unique file key - // const newKey = `${filename}-${uuidv4()}${fileType}`; const extension = fileType; const folderPath = foldername ? `${foldername}/` : ''; const newKey = `${folderPath}${filename}-${uuidv4()}${extension}`; - + // Create presigned POST with minimal restrictions const result = await createPresignedPost(this.s3Client, { Bucket: this.bucketName, Key: newKey, Conditions: [ - ['starts-with', '$Content-Type', 'image/'], - ["eq", "$Content-Type", contentType], // ✅ this enforces exact match - ["eq", "$key", newKey], // ✅ makes sure they don't change key - ["content-length-range", 0, 5 * 1024 * 1024], // max 5MB - ]as any[], + // Only enforce the key to prevent tampering + ["eq", "$key", newKey], + // Allow any content type + ["starts-with", "$Content-Type", ""], + // No file size limit (remove content-length-range) + ] as any[], Fields: { key: newKey, "Content-Type": contentType }, - Expires: 300 // 5 minutes + Expires: 24 * 60 * 60 // 24 hours instead of 5 minutes }); return APIResponse.success( @@ -130,14 +165,7 @@ export class UploadS3Service { API_RESPONSES.SIGNED_URL_SUCCESS ); } catch (error) { - console.error("Presigned URL Error:", error); - return APIResponse.error( - response, - APIID.SIGNED_URL, - API_RESPONSES.BAD_REQUEST, - API_RESPONSES.SIGNED_URL_FAILED, - HttpStatus.BAD_REQUEST - ); + throw new Error(`Failed to generate presigned URL: ${error.message}`); } } diff --git a/src/common/services/upload-file.ts b/src/common/services/upload-file.ts index 834ae6bc..d24d8866 100644 --- a/src/common/services/upload-file.ts +++ b/src/common/services/upload-file.ts @@ -23,11 +23,31 @@ export class FilesUploadService { } async saveFile(file: Express.Multer.File): Promise<{ filePath: string; fileSize: number }> { - const allowedExtensions: string[] = ['.jpg', '.jpeg', '.png', '.gif', '.ico', '.webp']; + const allowedExtensions: string[] = [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".ico", + ".webp", + ".mp4", + ".mp3", + ".pdf", + ".doc" + ]; const fileExtension = extname(file.originalname).toLowerCase(); if (!allowedExtensions.includes(fileExtension)) { - throw new BadRequestException(`Invalid file type: '${fileExtension}'. Allowed file types are: '.jpg', '.jpeg', '.png', '.gif', '.ico', '.webp'.` + throw new BadRequestException(`Invalid file type: '${fileExtension}'. Allowed file types are: ".jpg", + ".jpeg", + ".png", + ".gif", + ".ico", + ".webp", + ".mp4", + ".mp3", + ".pdf", + ".doc"` ); } diff --git a/src/common/utils/keycloak.adapter.util.ts b/src/common/utils/keycloak.adapter.util.ts index 3cc9f4f0..67867e74 100644 --- a/src/common/utils/keycloak.adapter.util.ts +++ b/src/common/utils/keycloak.adapter.util.ts @@ -75,7 +75,7 @@ async function createUserInKeyCloak(query, token, role: string) { value: query.password, }, ], - attributes : { + attributes: { // Multi tenant for roles is not currently supported in keycloak user_roles: [role] // Added in attribute and mappers } @@ -97,28 +97,22 @@ async function createUserInKeyCloak(query, token, role: string) { // Log and return the created user's ID const userId = response.headers.location.split("/").pop(); // Extract user ID from the location header - return { statusCode: response.status, message: "User created successfully", userId : userId }; + return { statusCode: response.status, message: "User created successfully", userId: userId }; } catch (error) { // Handle errors and log relevant details if (error.response) { - console.error("Error Response Status:", error.response.status); - console.error("Error Response Data:", error.response.data); - console.error("Error Response Headers:", error.response.headers); - return { statusCode: error.response.status, message: error.response.data.errorMessage || "Error occurred during user creation", email: query.email || "No email provided", }; } else if (error.request) { - console.error("No response received:", error.request); return { statusCode: 500, message: "No response received from Keycloak", email: query.email || "No email provided", }; } else { - console.error("Error setting up request:", error.message); return { statusCode: 500, message: `Error setting up request: ${error.message}`, @@ -264,12 +258,77 @@ async function checkIfUsernameExistsInKeycloak(username, token) { return userResponse; } +// Define the structure for user enable/disable operation +interface UpdateUserEnabledQuery { + userId: string; + enabled: boolean; +} + +// Define the structure of the function response +interface UpdateUserEnabledResponse { + success: boolean; + statusCode: number; + message: string; +} + +async function updateUserEnabledStatusInKeycloak( + query: UpdateUserEnabledQuery, + token: string +): Promise { + // Validate required parameters + if (!query.userId) { + return { + success: false, + statusCode: 400, + message: "User status cannot be updated, userId missing", + }; + } + + // Prepare the payload for the update + const data = JSON.stringify({ + enabled: query.enabled, + }); + + // Axios request configuration + const config: AxiosRequestConfig = { + method: "put", + url: `${process.env.KEYCLOAK}${process.env.KEYCLOAK_ADMIN}/${query.userId}`, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + data: data, + }; + + try { + // Perform the Axios request + const response: AxiosResponse = await axios(config); + return { + success: true, + statusCode: response.status, + message: `User ${query.enabled ? 'enabled' : 'disabled'} successfully in Keycloak`, + }; + } catch (error: any) { + // Extract error details + const axiosError: AxiosError = error; + const errorMessage = + axiosError.response?.data?.errorMessage || `Failed to ${query.enabled ? 'enable' : 'disable'} user in Keycloak`; + + return { + success: false, + statusCode: axiosError.response?.status || 500, + message: errorMessage, + }; + } +} + export { getUserGroup, getUserRole, getKeycloakAdminToken, createUserInKeyCloak, updateUserInKeyCloak, + updateUserEnabledStatusInKeycloak, checkIfEmailExistsInKeycloak, checkIfUsernameExistsInKeycloak, }; diff --git a/src/common/utils/notification.axios.ts b/src/common/utils/notification.axios.ts index 789b427c..f29a9749 100644 --- a/src/common/utils/notification.axios.ts +++ b/src/common/utils/notification.axios.ts @@ -40,6 +40,70 @@ export class NotificationRequest { const statusCode = error.response.status; const errorDetails = error.response.data || API_RESPONSES.ERROR; + switch (statusCode) { + case 400: + throw new HttpException( + `Bad Request: ${ + errorDetails.params?.errmsg || API_RESPONSES.BAD_REQUEST + }`, + HttpStatus.BAD_REQUEST + ); + case 404: + throw new HttpException( + `Not Found: ${ + errorDetails.params?.errmsg || API_RESPONSES.NOT_FOUND + }`, + HttpStatus.NOT_FOUND + ); + case 500: + throw new HttpException( + `Internal Server Error: ${ + errorDetails.params?.errmsg || + API_RESPONSES.INTERNAL_SERVER_ERROR + }`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + default: + throw new HttpException( + `Unexpected Error: ${ + errorDetails.params?.errmsg || API_RESPONSES.UNEXPECTED_ERROR + }`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + throw new HttpException( + API_RESPONSES.INTERNAL_SERVER_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + async sendRawNotification(body) { + const data = JSON.stringify(body); + const config: AxiosRequestConfig = { + method: "POST", + maxBodyLength: Infinity, + url: `${this.url}/notification/send-raw`, + headers: { + "Content-Type": "application/json", + }, + data: data, + }; + try { + const response = await axios.request(config); + return response.data; + } catch (error) { + if (error.code === "ECONNREFUSED") { + throw new HttpException( + API_RESPONSES.SERVICE_UNAVAILABLE, + HttpStatus.SERVICE_UNAVAILABLE + ); + } + if (error.response) { + const statusCode = error.response.status; + const errorDetails = error.response.data || API_RESPONSES.ERROR; + + switch (statusCode) { case 400: throw new HttpException( diff --git a/src/common/utils/response.messages.ts b/src/common/utils/response.messages.ts index 3d3da252..f659c3ef 100644 --- a/src/common/utils/response.messages.ts +++ b/src/common/utils/response.messages.ts @@ -213,7 +213,9 @@ export const API_RESPONSES = { EMAIL_ERROR: "Email notification failed", SIGNED_URL_SUCCESS: "Signed URL generated successfully", SIGNED_URL_FAILED: "Error while generating signed URL", - INVALID_FILE_TYPE: "Invalid file type. Allowed file types are: '.jpg','.jpeg','.png','.webp','.pdf','.doc','.docx','.mp4','.mov','.txt','.csv'", - FILE_SIZE_ERROR: "File too large. Maximum allowed file size is 10MB." + INVALID_FILE_TYPE: "Invalid file type. Allowed file types are: '.jpg','.jpeg','.png','.webp','.pdf','.doc','.docx','.mp4','.mov','.txt','.csv','.mp3", + FILE_SIZE_ERROR: "File too large. Maximum allowed file size is 10MB.", + WHATSAPP_ERROR: "WhatsApp notification failed", + WHATSAPP_NOTIFICATION_ERROR: "Failed to send WhatsApp notification:", }; diff --git a/src/constants/routeconfig.js b/src/constants/routeconfig.js index b160e8c2..c9329dc9 100644 --- a/src/constants/routeconfig.js +++ b/src/constants/routeconfig.js @@ -390,6 +390,19 @@ } ] }, + { + "sourceRoute": "/interface/v1/academicyears/create", + "type": "POST", + "priority": "MUST_HAVE", + "inSequence": false, + "orchestrated": false, + "targetPackages": [ + { + "basePackageName": "user", + "packageName": "shiksha-user" + } + ] + }, { "sourceRoute": "/interface/v1/cohortmember/bulkCreate", "type": "POST", diff --git a/src/fields/fields.module.ts b/src/fields/fields.module.ts index fab68345..06e7c45d 100644 --- a/src/fields/fields.module.ts +++ b/src/fields/fields.module.ts @@ -1,4 +1,4 @@ -import { CacheModule, Module } from "@nestjs/common"; +import { Module } from "@nestjs/common"; import { FieldsController } from "./fields.controller"; import { HttpModule } from "@nestjs/axios"; import { FieldsAdapter } from "./fieldsadapter"; diff --git a/src/forms/forms.controller.ts b/src/forms/forms.controller.ts index 79d8b07c..e0e6ec43 100644 --- a/src/forms/forms.controller.ts +++ b/src/forms/forms.controller.ts @@ -23,6 +23,8 @@ import { APIID } from '@utils/api-id.config'; import { isUUID } from 'class-validator'; import { API_RESPONSES } from '@utils/response.messages'; import { GetUserId } from "src/common/decorators/getUserId.decorator"; +import { Request, Response } from "express"; + @Controller("form") @ApiTags("Forms") diff --git a/src/health.controller.spec.ts b/src/health.controller.spec.ts new file mode 100644 index 00000000..0188bd6c --- /dev/null +++ b/src/health.controller.spec.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HealthController } from './health.controller'; +import { DataSource } from 'typeorm'; + +describe('HealthController', () => { + let controller: HealthController; + let dataSource: DataSource; + + beforeEach(async () => { + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: DataSource, + useValue: mockDataSource, + }, + ], + }).compile(); + + controller = module.get(HealthController); + dataSource = module.get(DataSource); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getHealth', () => { + it('should return healthy status when database is accessible', async () => { + // Mock successful database query + jest.spyOn(dataSource, 'query').mockResolvedValue([{ '?column?': 1 }]); + + const result = await controller.getHealth(); + + expect(result.id).toBe('api.content.health'); + expect(result.ver).toBe('3.0'); + expect(result.responseCode).toBe('OK'); + expect(result.params.status).toBe('successful'); + expect(result.result.healthy).toBe(true); + expect(result.result.checks).toEqual([ + { name: 'postgres db', healthy: true } + ]); + expect(result.params.resmsgid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it('should return unhealthy status when database is not accessible', async () => { + // Mock database query failure + jest.spyOn(dataSource, 'query').mockRejectedValue(new Error('Connection failed')); + + const result = await controller.getHealth(); + + expect(result.id).toBe('api.content.health'); + expect(result.ver).toBe('3.0'); + expect(result.responseCode).toBe('OK'); + expect(result.params.status).toBe('successful'); + expect(result.result.healthy).toBe(false); + expect(result.result.checks).toEqual([ + { name: 'postgres db', healthy: false } + ]); + }); + }); +}); \ No newline at end of file diff --git a/src/health.controller.ts b/src/health.controller.ts new file mode 100644 index 00000000..6f2fee1d --- /dev/null +++ b/src/health.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; + +@Controller() +export class HealthController { + constructor(private readonly dataSource: DataSource) {} + + @Get('health') + async getHealth() { + let dbHealthy = false; + + try { + // Check database connectivity with a simple query + await this.dataSource.query('SELECT 1'); + dbHealthy = true; + } catch (error) { + dbHealthy = false; + } + + const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'ZZ'); + + return { + id: 'api.content.health', + ver: '3.0', + ts: timestamp, + params: { + resmsgid: uuidv4(), + msgid: null, + err: null, + status: 'successful', + errmsg: null, + }, + responseCode: 'OK', + result: { + checks: [ + { name: 'postgres db', healthy: dbHealthy } + ], + healthy: dbHealthy, + }, + }; + } +} \ No newline at end of file diff --git a/src/kafka/kafka.service.ts b/src/kafka/kafka.service.ts index ad4a3b93..6f60829f 100644 --- a/src/kafka/kafka.service.ts +++ b/src/kafka/kafka.service.ts @@ -1,17 +1,19 @@ import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Kafka, Producer } from 'kafkajs'; +import { Kafka, Producer, Admin } from 'kafkajs'; @Injectable() export class KafkaService implements OnModuleInit, OnModuleDestroy { private readonly kafka: Kafka; private producer: Producer; + private admin: Admin; private readonly logger = new Logger(KafkaService.name); private isKafkaEnabled: boolean; // Flag to check if Kafka is enabled + private topicsCreated: Set = new Set(); // Track created topics constructor(private configService: ConfigService) { // Retrieve Kafka config from the configuration - this.isKafkaEnabled = this.configService.get('kafkaEnabled', true); // Default to true if not specified + this.isKafkaEnabled = this.configService.get('kafkaEnabled', false); // Default to true if not specified const brokers = this.configService.get('KAFKA_BROKERS', 'localhost:9092').split(','); const clientId = this.configService.get('KAFKA_CLIENT_ID', 'user-service'); @@ -27,16 +29,18 @@ export class KafkaService implements OnModuleInit, OnModuleDestroy { }); this.producer = this.kafka.producer(); + this.admin = this.kafka.admin(); } } async onModuleInit() { if (this.isKafkaEnabled) { try { + await this.connectAdmin(); await this.connectProducer(); - this.logger.log('Kafka producer initialized successfully'); + this.logger.log('Kafka producer and admin initialized successfully'); } catch (error) { - this.logger.error('Failed to initialize Kafka producer', error); + this.logger.error('Failed to initialize Kafka services', error); } } else { this.logger.log('Kafka is disabled. Skipping producer initialization.'); @@ -46,6 +50,7 @@ export class KafkaService implements OnModuleInit, OnModuleDestroy { async onModuleDestroy() { if (this.isKafkaEnabled) { await this.disconnectProducer(); + await this.disconnectAdmin(); } } @@ -68,6 +73,78 @@ export class KafkaService implements OnModuleInit, OnModuleDestroy { } } + private async connectAdmin() { + try { + await this.admin.connect(); + this.logger.log('Kafka admin connected'); + } catch (error) { + this.logger.error(`Failed to connect Kafka admin: ${error.message}`, error.stack); + throw error; + } + } + + private async disconnectAdmin() { + try { + await this.admin.disconnect(); + this.logger.log('Kafka admin disconnected'); + } catch (error) { + this.logger.error(`Failed to disconnect Kafka admin: ${error.message}`, error.stack); + } + } + + /** + * Ensure a topic exists, creating it if necessary + * + * @param topicName - The name of the topic to ensure exists + * @returns A promise that resolves when the topic is confirmed to exist + */ + private async ensureTopicExists(topicName: string): Promise { + if (!this.isKafkaEnabled) { + return; + } + + // Check if we've already created this topic in this session + if (this.topicsCreated.has(topicName)) { + return; + } + + try { + // Get list of existing topics + const existingTopics = await this.admin.listTopics(); + + // Check if topic exists + if (existingTopics.includes(topicName)) { + this.topicsCreated.add(topicName); + this.logger.debug(`Topic ${topicName} already exists`); + return; + } + + // Create the topic if it doesn't exist + await this.admin.createTopics({ + topics: [ + { + topic: topicName, + numPartitions: 1, // You can make this configurable + replicationFactor: 1, // You can make this configurable + }, + ], + }); + + this.topicsCreated.add(topicName); + this.logger.log(`Topic ${topicName} created successfully`); + } catch (error) { + // Topic might already exist, check if it's a "topic already exists" error + if (error.message && error.message.includes('already exists')) { + this.topicsCreated.add(topicName); + this.logger.debug(`Topic ${topicName} already exists`); + return; + } + + this.logger.error(`Failed to ensure topic ${topicName} exists: ${error.message}`, error.stack); + throw error; + } + } + /** * Publish a message to a Kafka topic * @@ -83,6 +160,9 @@ export class KafkaService implements OnModuleInit, OnModuleDestroy { } try { + // Ensure the topic exists before publishing + await this.ensureTopicExists(topic); + const payload = { topic, messages: [ @@ -114,10 +194,24 @@ export class KafkaService implements OnModuleInit, OnModuleDestroy { return; // Do nothing if Kafka is disabled } - const topic = this.configService.get('KAFKA_TOPIC', 'user-events'); - + const topic = this.configService.get('KAFKA_TOPIC', 'user-topic'); + let fullEventType = ''; + switch (eventType) { + case 'created': + fullEventType = 'USER_CREATED'; + break; + case 'updated': + fullEventType = 'USER_UPDATED'; + break; + case 'deleted': + fullEventType = 'USER_DELETED'; + break; + default: + fullEventType = 'UNKNOWN_EVENT'; + break; + } const payload = { - eventType, + eventType: fullEventType, timestamp: new Date().toISOString(), userId, data: userData @@ -126,4 +220,44 @@ export class KafkaService implements OnModuleInit, OnModuleDestroy { await this.publishMessage(topic, payload, userId); this.logger.log(`User ${eventType} event published for user ${userId}`); } + + /** + * Publish a cohort-related event to Kafka + * + * @param eventType - The type of cohort event (created, updatetrued, deleted) + * @param cohortData - The cohort data to include in the event + * @param cohortId - The ID of the cohort (used as the message key) + */ + async publishCohortEvent(eventType: 'created' | 'updated' | 'deleted', cohortData: any, cohortId: string): Promise { + if (!this.isKafkaEnabled) { + this.logger.warn('Kafka is disabled. Skipping cohort event publish.'); + return; // Do nothing if Kafka is disabled + } + + const topic = this.configService.get('KAFKA_TOPIC', 'user-topic'); + let fullEventType = ''; + switch (eventType) { + case 'created': + fullEventType = 'COHORT_CREATED'; + break; + case 'updated': + fullEventType = 'COHORT_UPDATED'; + break; + case 'deleted': + fullEventType = 'COHORT_DELETED'; + break; + default: + fullEventType = 'UNKNOWN_EVENT'; + break; + } + const payload = { + eventType: fullEventType, + timestamp: new Date().toISOString(), + cohortId, + data: cohortData + }; + + await this.publishMessage(topic, payload, cohortId); + this.logger.log(`Cohort ${eventType} event published for cohort ${cohortId}`); + } } diff --git a/src/tenant/dto/tenant-create.dto.ts b/src/tenant/dto/tenant-create.dto.ts index 4a7957f3..44e80f74 100644 --- a/src/tenant/dto/tenant-create.dto.ts +++ b/src/tenant/dto/tenant-create.dto.ts @@ -36,6 +36,10 @@ export class TenantCreateDto { @IsOptional() params?: object; + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + contentFilter?: object; + //file path @ApiPropertyOptional({ type: () => [String] }) @IsArray() diff --git a/src/tenant/dto/tenant-update.dto.ts b/src/tenant/dto/tenant-update.dto.ts index 3143f450..9f989274 100644 --- a/src/tenant/dto/tenant-update.dto.ts +++ b/src/tenant/dto/tenant-update.dto.ts @@ -20,6 +20,10 @@ export class TenantUpdateDto { @IsOptional() params?: object; + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + contentFilter?: object; + //file path @ApiPropertyOptional({ type: () => [String] }) @IsOptional() diff --git a/src/tenant/tenant.controller.ts b/src/tenant/tenant.controller.ts index 5a375edc..340cad2c 100644 --- a/src/tenant/tenant.controller.ts +++ b/src/tenant/tenant.controller.ts @@ -60,7 +60,6 @@ export class TenantController { @GetUserId("userId", ParseUUIDPipe) userId: string ): Promise { const uploadedFiles = []; - // Loop through each file and upload it if (files && files.length > 0) { for (const file of files) { diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index bd423b43..1b3dc53c 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -1,6 +1,6 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { Tenant } from './entities/tenent.entity'; -import { ILike, In,Repository } from 'typeorm'; +import { ILike, In, Repository } from 'typeorm'; import APIResponse from "src/common/responses/response"; import { InjectRepository } from '@nestjs/typeorm'; import { API_RESPONSES } from '@utils/response.messages'; @@ -38,14 +38,14 @@ export class TenantService { let query = `SELECT * FROM public."Roles" WHERE "tenantId" = '${tenantData.tenantId}'`; let getRole = await this.tenantRepository.query(query); - if(getRole.length == 0){ + if (getRole.length == 0) { let query = `SELECT * FROM public."Roles"`; getRole = await this.tenantRepository.query(query); } // Add role details to the tenantData object let roleDetails = []; - if(getRole.length == 0){ + if (getRole.length == 0) { tenantData['role'] = null; } @@ -86,7 +86,7 @@ export class TenantService { public async searchTenants(request: Request, tenantSearchDTO: TenantSearchDTO, response: Response): Promise { let apiId = APIID.TENANT_SEARCH; - try { + try { const { limit, offset, filters } = tenantSearchDTO; const whereClause: Record = {}; @@ -96,7 +96,7 @@ export class TenantService { case 'name': whereClause[key] = ILike(`%${value}%`); break; - + case 'status': if (Array.isArray(value)) { whereClause[key] = In(value); @@ -104,12 +104,12 @@ export class TenantService { whereClause[key] = value; } break; - + default: if (value !== undefined && value !== null) { whereClause[key] = value; } - break; + break; } }); } @@ -157,9 +157,26 @@ export class TenantService { } } - public async createTenants( tenantCreateDto: TenantCreateDto, response:Response): Promise { + public async createTenants(tenantCreateDto: TenantCreateDto, response: Response): Promise { let apiId = APIID.TENANT_CREATE; try { + // Parse JSON strings for params and contentFilter fields + if (tenantCreateDto.params && typeof tenantCreateDto.params === 'string') { + try { + tenantCreateDto.params = JSON.parse(tenantCreateDto.params); + } catch (error) { + LoggerUtil.warn(`Failed to parse params field: ${error.message}`, apiId); + } + } + + if (tenantCreateDto.contentFilter && typeof tenantCreateDto.contentFilter === 'string') { + try { + tenantCreateDto.contentFilter = JSON.parse(tenantCreateDto.contentFilter); + } catch (error) { + LoggerUtil.warn(`Failed to parse contentFilter field: ${error.message}`, apiId); + } + } + let checkExitTenants = await this.tenantRepository.find({ where: { "name": tenantCreateDto?.name @@ -232,7 +249,7 @@ export class TenantService { result, HttpStatus.OK, API_RESPONSES.TENANT_DELETE, - ); + ); } } catch (error) { const errorMessage = error.message || API_RESPONSES.INTERNAL_SERVER_ERROR; @@ -254,6 +271,23 @@ export class TenantService { public async updateTenants(tenantId: string, tenantUpdateDto: TenantUpdateDto, response: Response) { let apiId = APIID.TENANT_UPDATE; try { + // Parse JSON strings for params and contentFilter fields + if (tenantUpdateDto.params && typeof tenantUpdateDto.params === 'string') { + try { + tenantUpdateDto.params = JSON.parse(tenantUpdateDto.params); + } catch (error) { + LoggerUtil.warn(`Failed to parse params field: ${error.message}`, apiId); + } + } + + if (tenantUpdateDto.contentFilter && typeof tenantUpdateDto.contentFilter === 'string') { + try { + tenantUpdateDto.contentFilter = JSON.parse(tenantUpdateDto.contentFilter); + } catch (error) { + LoggerUtil.warn(`Failed to parse contentFilter field: ${error.message}`, apiId); + } + } + let checkExistingTenant = await this.tenantRepository.findOne({ where: { tenantId } }) @@ -293,7 +327,7 @@ export class TenantService { { tenantId, updatedFields: tenantUpdateDto }, // Return updated tenant information HttpStatus.OK, API_RESPONSES.TENANT_UPDATE - ); + ); } } catch (error) { diff --git a/src/user/dto/otpVerify.dto.ts b/src/user/dto/otpVerify.dto.ts index d8e1652f..de65b47f 100644 --- a/src/user/dto/otpVerify.dto.ts +++ b/src/user/dto/otpVerify.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsIn, IsString, Length, Matches, ValidateIf, registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator'; +import { Transform } from 'class-transformer'; // Custom validator function function IsValidOtp(validationOptions?: ValidationOptions) { @@ -26,7 +27,14 @@ function IsValidOtp(validationOptions?: ValidationOptions) { export class OtpVerifyDTO { @ApiProperty() - @ValidateIf(o => o.reason === 'signup') + @ValidateIf(o => o.reason === 'signup' || o.reason === 'login') + @Transform(({ value }) => { + if (value === undefined || value === null) return value; + const digits = String(value).replace(/\D/g, ''); + if (digits.length === 12 && digits.startsWith('91')) return digits.slice(2); // handle +91xxxxxxxxxx + if (digits.length === 11 && digits.startsWith('0')) return digits.slice(1); // handle 0-prefixed + return digits; + }) @IsString({ message: 'Mobile number must be a string.' }) @Matches(/^\d{10}$/, { message: 'Mobile number must be exactly 10 digits.' }) mobile: string; @@ -43,7 +51,7 @@ export class OtpVerifyDTO { @ApiProperty() @IsString({ message: 'Reason must be a string.' }) - @IsIn(['signup', 'forgot'], { message: 'Reason must be either "signup" or "forgot".' }) + @IsIn(['signup', 'login', 'forgot'], { message: 'Reason must be either "signup", "login" or "forgot".' }) reason: string; @ApiProperty() diff --git a/src/user/dto/user-update.dto.ts b/src/user/dto/user-update.dto.ts index 4dc616b3..1230719e 100644 --- a/src/user/dto/user-update.dto.ts +++ b/src/user/dto/user-update.dto.ts @@ -122,7 +122,7 @@ class UserDataDTO { @ApiProperty({ type: () => String }) @IsString() @IsOptional() - @IsEnum(UserStatus) + @IsEnum(UserStatus, { message: 'Invalid status value. Allowed values are: active, inactive, archived.' }) status: UserStatus; @ApiProperty({ type: () => String }) diff --git a/src/user/entities/user-entity.ts b/src/user/entities/user-entity.ts index 13a0c565..3655764e 100644 --- a/src/user/entities/user-entity.ts +++ b/src/user/entities/user-entity.ts @@ -31,9 +31,15 @@ export class User { @Column({ type: 'varchar', length: 50, nullable: false }) lastName: string; + @Column({ type: 'varchar', length: 100, nullable: true }) + name: string; + @Column({ type: 'enum', enum: ['male', 'female', 'transgender'], nullable: false }) gender: string; + @Column({ type: 'varchar', length: 50, nullable: false }) + enrollmentId: string; + @Column({ type: "date", nullable: true }) dob: Date;