diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3eba654 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log +Dockerfile* +docker-compose.yml +.git +.gitignore +*.md +*.log +*.env +*.test.js +test/ +dist/ + diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..259de13 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/.gitignore b/.gitignore index 2309cc8..5fbcbe4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# ---> Node # Logs logs *.log @@ -7,6 +6,9 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* +docker-compose-*.yml +Dockerfile +*.env # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json @@ -74,11 +76,11 @@ web_modules/ .yarn-integrity # dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local +# .env +# .env.development.local +# .env.test.local +# .env.production.local +# .env.local # parcel-bundler cache (https://parceljs.org/) .cache @@ -105,12 +107,6 @@ dist .temp .cache -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - # Docusaurus cache and generated files .docusaurus @@ -136,3 +132,22 @@ dist .yarn/install-state.gz .pnp.* +*.csv + +/uploads + +/csv_files + +*.md + +ERROR_EVENTS.ts + +FRONTEND_ERROR_HANDLING_EXAMPLE.ts + +*.html + +*.json +README.md + +/scripts +/public \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b393560 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +23 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..dcb7279 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/Dockerfile_old b/Dockerfile_old new file mode 100644 index 0000000..38c15f4 --- /dev/null +++ b/Dockerfile_old @@ -0,0 +1,40 @@ +# Stage 1: Build the application +FROM node:20 AS build + +# Set working directory +WORKDIR /app + +# Copy package.json and package-lock.json for caching npm install +COPY package*.json ./ + +# Install dependencies (use --frozen-lockfile for npm 7+) +RUN npm install + +# Copy the rest of the app +COPY . . + +# Build the NestJS application +RUN npm run build + +# Stage 2: Production environment +FROM node:20-slim AS production + +# Set working directory +WORKDIR /app + +# Copy only necessary files from the build stage +COPY --from=build /app/package*.json ./ +COPY --from=build /app/dist ./dist + +# Install production dependencies only +RUN npm ci --only=production + +# Expose the app port (default for NestJS) +EXPOSE 3000 + +# Set NODE_ENV to production +ENV NODE_ENV=production + +# Run the application +CMD ["node", "dist/main"] + diff --git a/Dockerfile_saman b/Dockerfile_saman new file mode 100644 index 0000000..8dde42e --- /dev/null +++ b/Dockerfile_saman @@ -0,0 +1,55 @@ +# Use the official Node.js image. +FROM docker.si24.ir/node:20-alpine AS builder + +# Create and change to the app directory. +WORKDIR /app + +# Copy application dependency manifests to the container image. +COPY package*.json ./ + +# Specify the non-root user and set ownership (with verbose mode) +RUN adduser -D ittalie +RUN chown -Rv ittalie:ittalie /app + +# Switch to the non-root user +USER ittalie + +# Install dependencies without running scripts +RUN npm config set registry https://repo.si24.ir/repository/npm/ +RUN npm ci --ignore-scripts -d + +# Copy only the necessary source code +COPY src/ ./src/ +COPY tsconfig.json ./ +COPY nest-cli.json ./ +COPY public ./public/ + +# Build the NestJS application. +RUN npm run build + +# --- Production Stage --- +FROM docker.si24.ir/node:22-alpine + +# Create and change to the app directory. +WORKDIR /app + +# Copy package.json and package-lock.json for production dependencies. +COPY package*.json ./ + +# Specify the non-root user and set ownership (with verbose mode) +RUN adduser -D ittalie +RUN chown -Rv ittalie:ittalie /app + +# Switch to the non-root user +USER ittalie + +# Install dependencies without running scripts +RUN npm config set registry https://repo.si24.ir/repository/npm/ +RUN npm ci --ignore-scripts -d + + +# Copy the built application from the builder stage. +COPY --from=builder /app/dist ./dist + +# Run the web service on container startup. +CMD ["npm", "run", "start:prod"] diff --git a/LICENSE b/LICENSE index cde4ac6..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,10 +1,201 @@ -This is free and unencumbered software released into the public domain. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. + 1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -For more information, please refer to + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/build_n_deploy.sh b/build_n_deploy.sh new file mode 100644 index 0000000..7e3c0c9 --- /dev/null +++ b/build_n_deploy.sh @@ -0,0 +1,12 @@ +git pull + +docker compose up -d --build + +docker run --rm \ + -e SONAR_HOST_URL="https://sq.ittalie.ir" \ + -e SONAR_TOKEN="sqp_4ec3cdc76b089f4c5b4575ac7c5b4a25ea3f6930" \ + -v "$(pwd):/usr/src" \ + sonarsource/sonar-scanner-cli \ + -Dsonar.projectKey=chatbot-v2-api \ + -Dsonar.sources=. + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32d2abf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: "3.8" + +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: chatbot-v2-api + environment: + - TZ=Asia/Tehran + ports: + - "4040:8585" + env_file: + - .local.env + networks: + - chatbot-v2 + restart: unless-stopped + volumes: + - chatbot-v2-api:/usr/src/uploads + - ./api-static:/usr/src/src/static + - /etc/localtime:/etc/localtime:ro + #- /etc/timezone:/etc/timezone:ro + deploy: + resources: + limits: + cpus: '1' + memory: '512M' + + redis: + image: redis:7.2-alpine + container_name: chatbot-v2-redis + volumes: + - chatbot-v2_redis_data:/data + ports: + - "4041:6379" + command: > + redis-server --appendonly yes --save 60 1 + restart: unless-stopped + networks: + - chatbot-v2 + + +networks: + chatbot-v2: + driver: bridge + +volumes: + chatbot-v2-api: + name: chatbot-v2-api + chatbot-v2_redis_data: + name: chatbot-v2_redis_data diff --git a/migration.js b/migration.js new file mode 100644 index 0000000..6d95a1f --- /dev/null +++ b/migration.js @@ -0,0 +1,59 @@ +const { MongoClient } = require('mongodb'); + +async function migrateUsers() { + const v1_uri = 'mongodb://localhost:27017/chatbot-parsian'; + const v2_uri = 'mongodb://localhost:27017/chatbot-v2-api'; + + const clientV1 = new MongoClient(v1_uri); + const clientV2 = new MongoClient(v2_uri); + + try { + await clientV1.connect(); + await clientV2.connect(); + + const dbV1 = clientV1.db(); + const dbV2 = clientV2.db(); + + const usersV1 = dbV1.collection('users'); + const usersV2 = dbV2.collection('users'); // Assuming the collection name is also 'users' in v2 + + const cursor = usersV1.find({}); + const usersToMigrate = []; + + for await (const userV1 of cursor) { + const userV2 = { + mobile: userV1.identity, + username: userV1.identity, + role: userV1.userType || 'user', // Default to 'user' if not specified + nationalCode: '', + otp: null, + createdAt: userV1.createdAt || new Date(), + updatedAt: userV1.updatedAt || new Date(), + family: '', + name: '', + birthDate: '', + address: '', + email: '', + otpAttempts: 0, + otpCreatedAt: null, + // Add any other fields from v1 that map directly to v2, or set default values for new v2 fields + }; + usersToMigrate.push(userV2); + } + + if (usersToMigrate.length > 0) { + await usersV2.insertMany(usersToMigrate); + console.log(`Successfully migrated ${usersToMigrate.length} users.`); + } else { + console.log('No users found to migrate.'); + } + + } catch (error) { + console.error('Error during migration:', error); + } finally { + await clientV1.close(); + await clientV2.close(); + } +} + +migrateUsers(); \ No newline at end of file diff --git a/src/acl/acl.controller.ts b/src/acl/acl.controller.ts new file mode 100644 index 0000000..b2d621b --- /dev/null +++ b/src/acl/acl.controller.ts @@ -0,0 +1,201 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AuditLogModel } from 'src/database/model/audit-log.model'; +import { AclService } from './acl.service'; +import { ChangeStaffRoleDto } from './dto/change-staff-role.dto'; +import { CreateCustomRoleDto } from './dto/create-custom-role.dto'; +import { CreateStaffDto } from './dto/create-staff.dto'; +import { SetStaffActiveDto } from './dto/set-staff-active.dto'; +import { UpdateRolePermissionsDto } from './dto/update-role-permissions.dto'; +import { UpdateUserOverridesDto } from './dto/update-user-overrides.dto'; + +@ApiTags('ACL (Owner)') +@Controller('acl') +@UseGuards(AdminGuard) +@ApiBearerAuth() +export class AclController { + constructor( + private readonly aclService: AclService, + @InjectModel(AuditLogModel.name) + private readonly auditLogModel: Model, + ) {} + + @Get('permissions/catalog') + @Permissions( + Permission.ProfileRead, // any authenticated staff can read catalog for UI; mutations remain Owner-only + ) + @ApiOperation({ summary: 'List assignable permission keys' }) + getCatalog() { + return this.aclService.listPermissionCatalog(); + } + + @Get('roles') + @ApiOperation({ summary: 'List role templates (Owner)' }) + async listRoles(@AdminIdentity() identity: any) { + await this.aclService.assertActorIsOwner(identity.userData._id); + return this.aclService.listRoles(); + } + + @Get('roles/:name') + @ApiOperation({ summary: 'Get one role template (Owner)' }) + async getRole(@AdminIdentity() identity: any, @Param('name') name: string) { + await this.aclService.assertActorIsOwner(identity.userData._id); + return this.aclService.getRole(name); + } + + @Patch('roles/:name') + @ApiOperation({ summary: 'Update role template permissions (Owner)' }) + async updateRole( + @AdminIdentity() identity: any, + @Param('name') name: string, + @Body() dto: UpdateRolePermissionsDto, + @Req() req: any, + ) { + return this.aclService.updateRolePermissions( + identity.userData._id, + name, + dto, + req, + ); + } + + @Post('roles') + @ApiOperation({ summary: 'Create custom role (Owner)' }) + async createRole( + @AdminIdentity() identity: any, + @Body() dto: CreateCustomRoleDto, + @Req() req: any, + ) { + return this.aclService.createCustomRole(identity.userData._id, dto, req); + } + + @Delete('roles/:name') + @ApiOperation({ summary: 'Delete custom role (Owner); blocked if users assigned' }) + async deleteRole( + @AdminIdentity() identity: any, + @Param('name') name: string, + @Req() req: any, + ) { + return this.aclService.deleteCustomRole(identity.userData._id, name, req); + } + + @Get('users/:userId/permissions') + @ApiOperation({ summary: 'Get effective permissions for a staff user (Owner)' }) + async getUserPermissions( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + ) { + await this.aclService.assertActorIsOwner(identity.userData._id); + return this.aclService.getUserEffectivePermissions(userId); + } + + @Patch('users/:userId/overrides') + @ApiOperation({ summary: 'Set permission grants/denies for a staff user (Owner)' }) + async updateOverrides( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + @Body() dto: UpdateUserOverridesDto, + @Req() req: any, + ) { + return this.aclService.updateUserOverrides( + identity.userData._id, + userId, + dto, + req, + ); + } + + @Patch('users/:userId/role') + @ApiOperation({ + summary: 'Change staff role (Owner); clears grants/denies', + }) + async changeRole( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + @Body() dto: ChangeStaffRoleDto, + @Req() req: any, + ) { + return this.aclService.changeStaffRole( + identity.userData._id, + userId, + dto, + req, + ); + } + + @Post('staff') + @ApiOperation({ + summary: + 'Create staff (admin/supervisor/expert/custom). Requires matching create permission; Owner always allowed.', + }) + async createStaff( + @AdminIdentity() identity: any, + @Body() dto: CreateStaffDto, + @Req() req: any, + ) { + return this.aclService.createStaff(identity.userData._id, dto, req); + } + + @Patch('staff/:userId/active') + @ApiOperation({ + summary: + 'Activate/deactivate staff. Same capability flags as create for that role.', + }) + async setActive( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + @Body() dto: SetStaffActiveDto, + @Req() req: any, + ) { + return this.aclService.setStaffActive( + identity.userData._id, + userId, + dto.isActive, + req, + ); + } + + @Get('audit-logs') + @ApiOperation({ summary: 'List RBAC/auth audit logs (Owner)' }) + async auditLogs( + @AdminIdentity() identity: any, + @Query('limit') limit = '50', + @Query('skip') skip = '0', + ) { + await this.aclService.assertActorIsOwner(identity.userData._id); + const lim = Math.min(Number(limit) || 50, 200); + const sk = Number(skip) || 0; + const filter = { + action: { + $regex: /^(acl\.|auth\.staff_)/, + }, + }; + const [items, total] = await Promise.all([ + this.auditLogModel + .find(filter) + .sort({ createdAt: -1 }) + .skip(sk) + .limit(lim) + .lean(), + this.auditLogModel.countDocuments(filter), + ]); + return { total, limit: lim, skip: sk, items }; + } +} diff --git a/src/acl/acl.module.ts b/src/acl/acl.module.ts new file mode 100644 index 0000000..446dca4 --- /dev/null +++ b/src/acl/acl.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { AclController } from './acl.controller'; +import { AclService } from './acl.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [AclController], + providers: [AclService], + exports: [AclService], +}) +export class AclModule {} diff --git a/src/acl/acl.service.ts b/src/acl/acl.service.ts new file mode 100644 index 0000000..e620b48 --- /dev/null +++ b/src/acl/acl.service.ts @@ -0,0 +1,456 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectModel } from '@nestjs/mongoose'; +import { createHash } from 'node:crypto'; +import { Model } from 'mongoose'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { + assertAssignablePermissions, + createPermissionForRole, + DEFAULT_ROLE_PERMISSIONS, + Permission, +} from 'src/common/types/permissions.catalog'; +import { + isOwnerRole, + isSystemStaffRole, + Role, + SYSTEM_STAFF_ROLES, +} from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { StaffRoleModel } from 'src/database/model/staff-role.model'; +import { PermissionsService } from './permissions.service'; +import { CreateStaffDto } from './dto/create-staff.dto'; +import { CreateCustomRoleDto } from './dto/create-custom-role.dto'; +import { UpdateRolePermissionsDto } from './dto/update-role-permissions.dto'; +import { UpdateUserOverridesDto } from './dto/update-user-overrides.dto'; +import { ChangeStaffRoleDto } from './dto/change-staff-role.dto'; + +@Injectable() +export class AclService implements OnModuleInit { + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(StaffRoleModel.name) + private readonly staffRoleModel: Model, + private readonly permissionsService: PermissionsService, + private readonly auditLogService: AuditLogService, + private readonly configService: ConfigService, + ) {} + + async onModuleInit() { + await this.seedSystemRoleTemplates(); + await this.seedOwnerFromEnv(); + } + + private async seedSystemRoleTemplates() { + for (const role of SYSTEM_STAFF_ROLES) { + await this.staffRoleModel.updateOne( + { name: role }, + { + $setOnInsert: { + name: role, + displayName: role.charAt(0).toUpperCase() + role.slice(1), + permissions: DEFAULT_ROLE_PERMISSIONS[role], + isSystem: true, + }, + }, + { upsert: true }, + ); + } + } + + private async seedOwnerFromEnv() { + const existingOwner = await this.adminModel.findOne({ role: Role.Owner }); + if (existingOwner) { + return; + } + + const email = + this.configService.get('OWNER_EMAIL') || process.env.OWNER_EMAIL; + const password = + this.configService.get('OWNER_PASSWORD') || + process.env.OWNER_PASSWORD; + const mobile = + this.configService.get('OWNER_MOBILE') || process.env.OWNER_MOBILE; + + if (!email || !password) { + console.warn( + '[ACL] No Owner in DB and OWNER_EMAIL/OWNER_PASSWORD not set — skipping Owner seed', + ); + return; + } + + const hashedPassword = createHash('sha256').update(password).digest('hex'); + await this.adminModel.create({ + email, + username: email, + password: hashedPassword, + mobile: mobile || undefined, + role: Role.Owner, + name: 'Owner', + family: '', + isActive: true, + permissionGrants: [], + permissionDenies: [], + }); + + await this.auditLogService.log({ + action: 'acl.owner_seeded', + resource: 'Admin', + metadata: { email }, + }); + } + + async assertActorIsOwner(actorId: string) { + const actor = await this.adminModel.findById(actorId).select('role isActive'); + if (!actor?.isActive || !isOwnerRole(actor.role)) { + throw new ForbiddenException('Only Owner can manage ACL'); + } + } + + listPermissionCatalog() { + return { + permissions: Object.values(Permission), + note: 'ACL meta operations are Owner-only and not assignable', + }; + } + + async listRoles() { + return this.staffRoleModel.find().sort({ isSystem: -1, name: 1 }).lean(); + } + + async getRole(name: string) { + const role = await this.staffRoleModel.findOne({ name }).lean(); + if (!role) throw new NotFoundException('role_not_found'); + return role; + } + + async updateRolePermissions( + actorId: string, + roleName: string, + dto: UpdateRolePermissionsDto, + req?: any, + ) { + await this.assertActorIsOwner(actorId); + if (roleName === Role.Owner) { + throw new BadRequestException('Owner is not an editable role template'); + } + + const permissions = assertAssignablePermissions(dto.permissions); + const role = await this.staffRoleModel.findOne({ name: roleName }); + if (!role) throw new NotFoundException('role_not_found'); + + const oldPermissions = [...role.permissions]; + role.permissions = permissions; + if (dto.displayName !== undefined) { + role.displayName = dto.displayName; + } + await role.save(); + + await this.auditLogService.logHttpRequest('acl.role_permissions_updated', req || {}, { + userId: actorId, + resource: 'StaffRole', + resourceId: roleName, + oldValues: { permissions: oldPermissions }, + newValues: { permissions }, + }); + + return role.toObject(); + } + + async createCustomRole(actorId: string, dto: CreateCustomRoleDto, req?: any) { + await this.assertActorIsOwner(actorId); + + const name = dto.name.trim().toLowerCase(); + if (!/^[a-z][a-z0-9_]{1,63}$/.test(name)) { + throw new BadRequestException( + 'Role name must be lowercase alphanumeric/underscore, starting with a letter', + ); + } + if (isOwnerRole(name) || isSystemStaffRole(name) || name === Role.User) { + throw new BadRequestException('Cannot create a role with a reserved name'); + } + + const existing = await this.staffRoleModel.findOne({ name }); + if (existing) throw new BadRequestException('role_already_exists'); + + const permissions = assertAssignablePermissions(dto.permissions); + const role = await this.staffRoleModel.create({ + name, + displayName: dto.displayName || name, + permissions, + isSystem: false, + }); + + await this.auditLogService.logHttpRequest('acl.role_created', req || {}, { + userId: actorId, + resource: 'StaffRole', + resourceId: name, + newValues: { permissions, displayName: role.displayName }, + }); + + return role.toObject(); + } + + async deleteCustomRole(actorId: string, roleName: string, req?: any) { + await this.assertActorIsOwner(actorId); + + const role = await this.staffRoleModel.findOne({ name: roleName }); + if (!role) throw new NotFoundException('role_not_found'); + if (role.isSystem) { + throw new BadRequestException('Cannot delete a system role'); + } + + const usersCount = await this.adminModel.countDocuments({ role: roleName }); + if (usersCount > 0) { + throw new BadRequestException( + `Cannot delete role while ${usersCount} user(s) are assigned; reassign them first`, + ); + } + + await role.deleteOne(); + + await this.auditLogService.logHttpRequest('acl.role_deleted', req || {}, { + userId: actorId, + resource: 'StaffRole', + resourceId: roleName, + }); + + return { deleted: true, name: roleName }; + } + + async getUserEffectivePermissions(userId: string) { + const admin = await this.adminModel + .findById(userId) + .select('role permissionGrants permissionDenies isActive email username') + .lean(); + if (!admin) throw new NotFoundException('staff_not_found'); + + const effective = await this.permissionsService.getEffectivePermissionsForStaff( + admin, + ); + + return { + userId, + role: admin.role, + permissionGrants: admin.permissionGrants || [], + permissionDenies: admin.permissionDenies || [], + effectivePermissions: [...effective], + }; + } + + async updateUserOverrides( + actorId: string, + userId: string, + dto: UpdateUserOverridesDto, + req?: any, + ) { + await this.assertActorIsOwner(actorId); + + const admin = await this.adminModel.findById(userId); + if (!admin) throw new NotFoundException('staff_not_found'); + if (isOwnerRole(admin.role)) { + throw new BadRequestException('Cannot set overrides on Owner'); + } + + const oldValues = { + permissionGrants: admin.permissionGrants || [], + permissionDenies: admin.permissionDenies || [], + }; + + if (dto.permissionGrants !== undefined) { + admin.permissionGrants = assertAssignablePermissions(dto.permissionGrants); + } + if (dto.permissionDenies !== undefined) { + admin.permissionDenies = assertAssignablePermissions(dto.permissionDenies); + } + await admin.save(); + + await this.auditLogService.logHttpRequest('acl.user_overrides_updated', req || {}, { + userId: actorId, + resource: 'Admin', + resourceId: userId, + oldValues, + newValues: { + permissionGrants: admin.permissionGrants, + permissionDenies: admin.permissionDenies, + }, + }); + + return this.getUserEffectivePermissions(userId); + } + + async changeStaffRole( + actorId: string, + userId: string, + dto: ChangeStaffRoleDto, + req?: any, + ) { + await this.assertActorIsOwner(actorId); + + const admin = await this.adminModel.findById(userId); + if (!admin) throw new NotFoundException('staff_not_found'); + if (isOwnerRole(admin.role)) { + throw new BadRequestException('Cannot change Owner role via API'); + } + if (isOwnerRole(dto.role)) { + throw new BadRequestException('Cannot promote to Owner via API'); + } + + const roleExists = await this.staffRoleModel.findOne({ name: dto.role }); + if (!roleExists) throw new NotFoundException('role_not_found'); + + const oldValues = { + role: admin.role, + permissionGrants: admin.permissionGrants || [], + permissionDenies: admin.permissionDenies || [], + }; + + admin.role = dto.role as Role; + admin.permissionGrants = []; + admin.permissionDenies = []; + await admin.save(); + + await this.auditLogService.logHttpRequest('acl.staff_role_changed', req || {}, { + userId: actorId, + resource: 'Admin', + resourceId: userId, + oldValues, + newValues: { + role: admin.role, + permissionGrants: [], + permissionDenies: [], + }, + }); + + return this.getUserEffectivePermissions(userId); + } + + async createStaff(actorId: string, dto: CreateStaffDto, req?: any) { + const actor = await this.adminModel.findById(actorId); + if (!actor?.isActive) throw new ForbiddenException('inactive_actor'); + + if (isOwnerRole(dto.role)) { + throw new BadRequestException('Cannot create Owner via API'); + } + + const requiredPerm = createPermissionForRole(dto.role); + if (!requiredPerm) { + // Custom role: only Owner may create users with custom roles for v1 + if (!isOwnerRole(actor.role)) { + throw new ForbiddenException( + 'Only Owner can create staff with custom roles', + ); + } + } else if (!isOwnerRole(actor.role)) { + const ok = await this.permissionsService.hasPermission(actorId, requiredPerm); + if (!ok) { + throw new ForbiddenException(`Missing permission: ${requiredPerm}`); + } + } + + const roleExists = await this.staffRoleModel.findOne({ name: dto.role }); + if (!roleExists) throw new NotFoundException('role_not_found'); + + const existing = await this.adminModel.findOne({ + $or: [{ email: dto.email }, { mobile: dto.mobile }], + }); + if (existing) { + throw new BadRequestException( + 'Staff with this email or mobile already exists', + ); + } + + const hashedPassword = createHash('sha256') + .update(dto.password) + .digest('hex'); + + const created = await this.adminModel.create({ + email: dto.email, + username: dto.email, + password: hashedPassword, + mobile: dto.mobile, + name: dto.name, + family: dto.family, + role: dto.role, + isActive: true, + permissionGrants: [], + permissionDenies: [], + }); + + const { password, ...safe } = created.toObject(); + + await this.auditLogService.logHttpRequest('acl.staff_created', req || {}, { + userId: actorId, + resource: 'Admin', + resourceId: String(created._id), + newValues: { email: dto.email, role: dto.role }, + }); + + return safe; + } + + async setStaffActive( + actorId: string, + targetId: string, + isActive: boolean, + req?: any, + ) { + const actor = await this.adminModel.findById(actorId); + if (!actor?.isActive) throw new ForbiddenException('inactive_actor'); + + const target = await this.adminModel.findById(targetId); + if (!target) throw new NotFoundException('staff_not_found'); + if (isOwnerRole(target.role)) { + throw new BadRequestException('Cannot deactivate Owner via API'); + } + if (String(target._id) === String(actorId)) { + throw new BadRequestException('Cannot deactivate yourself'); + } + + const requiredPerm = createPermissionForRole(target.role); + if (!requiredPerm) { + if (!isOwnerRole(actor.role)) { + throw new ForbiddenException( + 'Only Owner can deactivate staff with custom roles', + ); + } + } else if (!isOwnerRole(actor.role)) { + const ok = await this.permissionsService.hasPermission(actorId, requiredPerm); + if (!ok) { + throw new ForbiddenException(`Missing permission: ${requiredPerm}`); + } + } + + const oldActive = target.isActive; + target.isActive = isActive; + await target.save(); + + await this.auditLogService.logHttpRequest( + isActive ? 'acl.staff_activated' : 'acl.staff_deactivated', + req || {}, + { + userId: actorId, + resource: 'Admin', + resourceId: targetId, + oldValues: { isActive: oldActive }, + newValues: { isActive }, + }, + ); + + const { password, resetToken, ...safe } = target.toObject(); + return safe; + } + + async listAuditLogs(actorId: string, limit = 50, skip = 0) { + await this.assertActorIsOwner(actorId); + // Queried via AuditLogService model indirectly — use inject in controller path + return { limit, skip }; + } +} diff --git a/src/acl/dto/change-staff-role.dto.ts b/src/acl/dto/change-staff-role.dto.ts new file mode 100644 index 0000000..bccf8ff --- /dev/null +++ b/src/acl/dto/change-staff-role.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class ChangeStaffRoleDto { + @ApiProperty({ example: 'supervisor' }) + @IsString() + role: string; +} diff --git a/src/acl/dto/create-custom-role.dto.ts b/src/acl/dto/create-custom-role.dto.ts new file mode 100644 index 0000000..e1279ab --- /dev/null +++ b/src/acl/dto/create-custom-role.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString, Matches } from 'class-validator'; + +export class CreateCustomRoleDto { + @ApiProperty({ example: 'content_manager' }) + @IsString() + @Matches(/^[a-z][a-z0-9_]{1,63}$/) + name: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + displayName?: string; + + @ApiProperty({ type: [String], example: ['dictionaries.read', 'dictionaries.write'] }) + @IsArray() + @IsString({ each: true }) + permissions: string[]; +} diff --git a/src/acl/dto/create-staff.dto.ts b/src/acl/dto/create-staff.dto.ts new file mode 100644 index 0000000..5aad6f6 --- /dev/null +++ b/src/acl/dto/create-staff.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsOptional, + IsString, + Matches, + MinLength, +} from 'class-validator'; +import { Role } from 'src/common/types/role.type'; + +export class CreateStaffDto { + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsString() + @MinLength(6) + password: string; + + @ApiProperty() + @IsString() + @Matches(/^[0-9]{11}$/) + mobile: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + family?: string; + + @ApiProperty({ + description: + 'System role (admin|supervisor|expert) or custom role name. Owner cannot be created via API.', + example: Role.Expert, + }) + @IsString() + role: string; +} diff --git a/src/acl/dto/set-staff-active.dto.ts b/src/acl/dto/set-staff-active.dto.ts new file mode 100644 index 0000000..cbda76b --- /dev/null +++ b/src/acl/dto/set-staff-active.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +export class SetStaffActiveDto { + @ApiProperty() + @IsBoolean() + isActive: boolean; +} diff --git a/src/acl/dto/update-role-permissions.dto.ts b/src/acl/dto/update-role-permissions.dto.ts new file mode 100644 index 0000000..a1e4319 --- /dev/null +++ b/src/acl/dto/update-role-permissions.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; + +export class UpdateRolePermissionsDto { + @ApiProperty({ type: [String] }) + @IsArray() + @IsString({ each: true }) + permissions: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + displayName?: string; +} diff --git a/src/acl/dto/update-user-overrides.dto.ts b/src/acl/dto/update-user-overrides.dto.ts new file mode 100644 index 0000000..e36f159 --- /dev/null +++ b/src/acl/dto/update-user-overrides.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; + +export class UpdateUserOverridesDto { + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissionGrants?: string[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissionDenies?: string[]; +} diff --git a/src/acl/permissions.service.ts b/src/acl/permissions.service.ts new file mode 100644 index 0000000..8bc75f5 --- /dev/null +++ b/src/acl/permissions.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + ALL_ASSIGNABLE_PERMISSIONS, + Permission, +} from 'src/common/types/permissions.catalog'; +import { isOwnerRole } from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { StaffRoleModel } from 'src/database/model/staff-role.model'; + +@Injectable() +export class PermissionsService { + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(StaffRoleModel.name) + private readonly staffRoleModel: Model, + ) {} + + /** + * effective = (role.permissions ∪ grants) − denies + * Owner always has every assignable permission. + */ + computeEffective( + roleName: string, + rolePermissions: string[], + grants: string[] = [], + denies: string[] = [], + ): Set { + if (isOwnerRole(roleName)) { + return new Set(ALL_ASSIGNABLE_PERMISSIONS); + } + + const effective = new Set([ + ...rolePermissions, + ...(grants || []), + ]); + for (const deny of denies || []) { + effective.delete(deny); + } + return effective as Set; + } + + async getEffectivePermissionsForAdminId( + adminId: string, + ): Promise> { + const admin = await this.adminModel + .findById(adminId) + .select('role permissionGrants permissionDenies isActive') + .lean() + .exec(); + + if (!admin || !admin.isActive) { + return new Set(); + } + + return this.getEffectivePermissionsForStaff({ + role: admin.role, + permissionGrants: admin.permissionGrants, + permissionDenies: admin.permissionDenies, + }); + } + + async getEffectivePermissionsForStaff(staff: { + role: string; + permissionGrants?: string[]; + permissionDenies?: string[]; + }): Promise> { + if (isOwnerRole(staff.role)) { + return new Set(ALL_ASSIGNABLE_PERMISSIONS); + } + + const roleDoc = await this.staffRoleModel + .findOne({ name: staff.role }) + .select('permissions') + .lean() + .exec(); + + return this.computeEffective( + staff.role, + roleDoc?.permissions || [], + staff.permissionGrants || [], + staff.permissionDenies || [], + ); + } + + async hasPermission( + adminId: string, + required: Permission | Permission[], + ): Promise { + const effective = await this.getEffectivePermissionsForAdminId(adminId); + const needed = Array.isArray(required) ? required : [required]; + return needed.some((p) => effective.has(p)); + } + + hasAny(effective: Set, required: Permission[]): boolean { + return required.some((p) => effective.has(p)); + } +} diff --git a/src/ai-service/ai-service.module.ts b/src/ai-service/ai-service.module.ts new file mode 100644 index 0000000..0106c4b --- /dev/null +++ b/src/ai-service/ai-service.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AiServiceService } from './ai-service.service'; + +@Module({ + providers: [AiServiceService], + exports: [AiServiceService], +}) +export class AiServiceModule {} diff --git a/src/ai-service/ai-service.service.ts b/src/ai-service/ai-service.service.ts new file mode 100644 index 0000000..de710b5 --- /dev/null +++ b/src/ai-service/ai-service.service.ts @@ -0,0 +1,534 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import * as FormData from 'form-data'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class AiServiceService { + /** Headers for every AI HTTP call (`X-API-Key` from `AI_API_KEY`). */ + private aiHeaders(extra: Record = {}): Record { + const headers: Record = { + accept: 'application/json', + ...extra, + }; + const key = process.env.AI_API_KEY?.trim(); + if (key) { + headers['X-API-Key'] = key; + } + return headers; + } + + async ask(body, session) { + try { + if (!session) { + const data = JSON.stringify({ + user_input: body.user_input ?? body.question, + chat_history_raw: body.chat_history_raw ?? [], + user_insurance_data: body.user_insurance_data, + user_installments_data: body.user_installments_data, + }); + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.AI_QUERY_URL, + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + data: data, + }; + const chat = await axios.request(axiosConfig); + const aiResponse = chat.data.response; + return aiResponse; + } else { + let transformedData = body; + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.AI_QUERY_URL, + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + data: transformedData, + }; + const newChat = await axios.request(axiosConfig); + if (!newChat.data && newChat.data.response) throw new Error('AI_ERROR'); + const aiResponse = newChat.data.response; + return aiResponse; + } + } catch (err) { + console.log(err); + if (err.isAxiosError) { + const status = err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; + const message = err.response?.data || 'Internal Server Error'; + + return new BaseResponseDTO(status, 'ai_service_error', { + error: message, + }); + } + return new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'something_wrong', + null, + ); + } + } + + async wrapUp(data) { + try { + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.AI_WRAPUP_URL, + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + data: data, + }; + const wrapup = await axios.request(axiosConfig); + const aiResponse = wrapup.data.title; + return aiResponse; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async aiUploadTxt(file) { + try { + console.log(`[aiUploadTxt] Attempting to upload file: ${file.originalName}`); + const formData = new FormData(); + const fileStream = fs.createReadStream(file.filePath); + + formData.append('file', fileStream, { + filename: file.originalName, + contentType: file.mimetype + }); + + // Upload the file + const uploadResponse = await axios.post(process.env.AI_UPLOAD_URL, formData, { + headers: this.aiHeaders(formData.getHeaders() as Record), + timeout: 30000 + }); + + console.log(`[aiUploadTxt] File upload response for ${file.originalName}:`, uploadResponse.data); + + // Check if upload was successful + if (uploadResponse.status === 200) { + console.log(`[aiUploadTxt] File ${file.originalName} uploaded successfully. Initiating QA generation.`); + const qaResponse = await axios.post( + `${process.env.AI_QA_GENERATOR_URL}?file_name=${encodeURIComponent(file.originalName)}`, + {}, + { + headers: this.aiHeaders(), + timeout: 120000 // 2 minutes timeout for QA generation + } + ); + + console.log('QA Generation response:', qaResponse.data); + + // Consider any HTTP 200 as success regardless of body shape + if (qaResponse.status === 200) { + return { + success: true, + fileName: file.originalName, + }; + } + throw new Error('QA generation failed'); + } else { + throw new Error('File upload failed'); + } + + } catch (err) { + console.error('Error uploading file to AI service:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + + async aiUploadCsv(file) { + try { + console.log(`[aiUploadCsv] Attempting to upload file: ${file.originalName}`); + const formData = new FormData(); + formData.append('file', fs.createReadStream(file.filePath), { + filename: file.originalName, + contentType: file.mimetype, + }); + + const axiosConfig = { + method: 'post', + url: process.env.AI_UPLOAD_URL, + headers: this.aiHeaders(formData.getHeaders() as Record), + data: formData, + }; + + const response = await axios.request(axiosConfig); + console.log(`[aiUploadCsv] File upload successful for ${file.originalName}. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[aiUploadCsv] Error uploading file to AI service:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } + } + +async newCollection(collection_name: string, filename: string, description: string) { + try { + console.log(`[newCollection] Attempting to create new collection: ${collection_name} with filename: ${filename} and description: ${description}`); + + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + params.append('filename', filename); + params.append('description', description); + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/new-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[newCollection] Collection ${collection_name} created successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[newCollection] Error creating new collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async getCollections() { + try { + console.log(`[getCollections] Attempting to retrieve all collections.`); + const axiosConfig = { + method: 'get', + url: `${process.env.AI_COLLECTION_URL}/get-collections`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[getCollections] Collections retrieved successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[getCollections] Error getting collections:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async updateCollection(collection_name: string, filename: string, description: string) { + try { + console.log(`[updateCollection] Attempting to update collection: ${collection_name} with filename: ${filename} and description: ${description}`); + + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + params.append('filename', filename); + params.append('description', description); + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/update-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[updateCollection] Collection ${collection_name} updated successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[updateCollection] Error updating collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async replaceCollection(collection_name: string, filename: string, description?: string) { + try { + console.log(`[replaceCollection] Attempting to replace collection: ${collection_name} with filename: ${filename}${description ? ` and description: ${description}` : ''}`); + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + params.append('filename', filename); + if (description) { + params.append('description', description); + } + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/replace-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[replaceCollection] Collection ${collection_name} replaced successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('Error replacing collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async deactivateCollection(collection_name: string) { + try { + console.log(`[deactivateCollection] Attempting to deactivate collection: ${collection_name}`); + + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/deactivate-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[deactivateCollection] Collection ${collection_name} deactivated successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[deactivateCollection] Error deactivating collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + + private collectionBaseUrl(): string { + return String(process.env.AI_COLLECTION_URL || '').replace(/\/$/, ''); + } + + private throwAiError(err: any): never { + let status = 500; + let errorData: unknown = null; + if (err?.response) { + status = err.response.status; + errorData = err.response.data; + } else if (err?.request) { + errorData = 'No response received from AI service'; + } else { + errorData = err?.message ?? err; + } + throw new BaseResponseDTO(status, errorData as any, null); + } + + async getCollectionsWithDescriptions() { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/get-collections-with-descriptions`, + { headers: this.aiHeaders(), timeout: 60_000 }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async getCollectionDescription(collectionName: string) { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/get-collection-description/${encodeURIComponent(collectionName)}`, + { headers: this.aiHeaders(), timeout: 60_000 }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async checkCollections() { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/check-collections`, + { headers: this.aiHeaders(), timeout: 60_000 }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async exportCollectionPage( + collectionName: string, + page = 1, + pageSize = 50, + ) { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}`, + { + params: { page, page_size: pageSize }, + headers: this.aiHeaders(), + timeout: 120_000, + }, + ); + return response.data as { + collection: string; + page: number; + page_size: number; + total_items: number; + total_pages: number; + items: Array<{ id: string; q: string; a: string }>; + }; + } catch (err) { + this.throwAiError(err); + } + } + + async createExportItem( + collectionName: string, + body: { q: string; a: string }, + ) { + try { + const response = await axios.post( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}`, + body, + { + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + timeout: 60_000, + }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async updateExportItem( + collectionName: string, + itemId: string, + body: { q: string; a: string }, + ) { + try { + const response = await axios.put( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}/${encodeURIComponent(itemId)}`, + body, + { + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + timeout: 60_000, + }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async deleteExportItem(collectionName: string, itemId: string) { + try { + const response = await axios.delete( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}/${encodeURIComponent(itemId)}`, + { + headers: this.aiHeaders(), + timeout: 60_000, + }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } +} diff --git a/src/ai-v2/ai-v2-admin.controller.ts b/src/ai-v2/ai-v2-admin.controller.ts new file mode 100644 index 0000000..a1c2b8d --- /dev/null +++ b/src/ai-v2/ai-v2-admin.controller.ts @@ -0,0 +1,250 @@ +import { + Body, + Controller, + Delete, + Get, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { MAX_DICTIONARY_BYTES } from 'src/storage/storage.constants'; +import { AiV2Client } from './ai-v2.client'; +import { AiV2Exception } from './ai-v2.exception'; +import { AiV2Service } from './ai-v2.service'; +import { CursorPageQueryDto } from './dto/common-query.dto'; +import { CreateDomainDto, ListDomainsQueryDto, UpdateDomainDto } from './dto/domains.dto'; +import { ListFilesQueryDto } from './dto/files.dto'; +import { + CountPointsQueryDto, + CreatePointDto, + GetPointQueryDto, + ListPointsQueryDto, + PatchPointPayloadDto, + ReplacePointDto, + SearchPointsQueryDto, +} from './dto/points.dto'; +import { RetrievalQueryDto } from './dto/retrieval.dto'; + +@ApiTags('v2 AI') +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.DictionariesRead) +@Controller('ai/v2') +export class AiV2AdminController { + constructor( + private readonly client: AiV2Client, + private readonly aiV2: AiV2Service, + ) {} + + @Get('domains') + @ApiOperation({ summary: 'List tenant domains (AI collections)' }) + async listDomains(@Query() query: ListDomainsQueryDto) { + return this.aiV2.ok(await this.client.listDomains(query.include_disabled)); + } + + @Post('domains') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ summary: 'Create a tenant domain' }) + @ApiBody({ type: CreateDomainDto }) + async createDomain(@Body() body: CreateDomainDto) { + return this.aiV2.created(await this.client.createDomain(body)); + } + + @Patch('domains/:domain') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Update a tenant domain display name' }) + @ApiParam({ name: 'domain' }) + @ApiBody({ type: UpdateDomainDto }) + async updateDomain( + @Param('domain') domain: string, + @Body() body: UpdateDomainDto, + ) { + return this.aiV2.ok(await this.client.updateDomain(domain, body)); + } + + @Delete('domains/:domain') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Disable a tenant domain' }) + @ApiParam({ name: 'domain' }) + async disableDomain(@Param('domain') domain: string) { + return this.aiV2.ok(await this.client.disableDomain(domain)); + } + + @Post('domains/:domain/enable') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Enable a tenant domain' }) + @ApiParam({ name: 'domain' }) + async enableDomain(@Param('domain') domain: string) { + return this.aiV2.ok(await this.client.enableDomain(domain)); + } + + @Get('domains/:domain/points') + @ApiOperation({ summary: 'List points in a domain' }) + @ApiParam({ name: 'domain' }) + async listDomainPoints( + @Param('domain') domain: string, + @Query() query: CursorPageQueryDto, + ) { + return this.aiV2.ok(await this.client.listDomainPoints(domain, query)); + } + + @Post('files') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ summary: 'Upload a file into a domain for ingestion' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'domain'], + properties: { + file: { type: 'string', format: 'binary' }, + domain: { type: 'string', example: 'faq' }, + }, + }, + }) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: MAX_DICTIONARY_BYTES }, + }), + ) + async uploadFile( + @UploadedFile() file: Express.Multer.File, + @Body('domain') domain: string, + ) { + if (!file) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'file_required'); + } + if (!domain?.trim()) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'domain_required'); + } + return this.aiV2.created(await this.client.uploadFile(file, domain.trim())); + } + + @Get('files') + @ApiOperation({ summary: 'List uploaded files' }) + async listFiles(@Query() query: ListFilesQueryDto) { + return this.aiV2.ok(await this.client.listFiles(query)); + } + + @Get('files/:fileId') + @ApiOperation({ summary: 'Get file status / ingestion progress' }) + @ApiParam({ name: 'fileId' }) + async getFile(@Param('fileId') fileId: string) { + return this.aiV2.ok(await this.client.getFile(fileId)); + } + + @Delete('files/:fileId') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Delete a file (soft-deletes its points)' }) + @ApiParam({ name: 'fileId' }) + async deleteFile(@Param('fileId') fileId: string) { + return this.aiV2.ok(await this.client.deleteFile(fileId)); + } + + @Get('files/:fileId/points') + @ApiOperation({ summary: 'List points for a file' }) + @ApiParam({ name: 'fileId' }) + async listFilePoints( + @Param('fileId') fileId: string, + @Query() query: CursorPageQueryDto, + ) { + return this.aiV2.ok(await this.client.listFilePoints(fileId, query)); + } + + @Get('points/count') + @ApiOperation({ summary: 'Count points, optionally filtered by domain and/or file' }) + async countPoints(@Query() query: CountPointsQueryDto) { + return this.aiV2.ok(await this.client.countPoints(query)); + } + + @Get('points/search') + @ApiOperation({ summary: 'Keyword search points (not semantic retrieval)' }) + async searchPoints(@Query() query: SearchPointsQueryDto) { + return this.aiV2.ok(await this.client.searchPoints(query)); + } + + @Get('points') + @ApiOperation({ summary: 'List points for a file_id' }) + @ApiQuery({ name: 'file_id', required: true }) + async listPoints(@Query() query: ListPointsQueryDto) { + return this.aiV2.ok(await this.client.listPoints(query)); + } + + @Post('points') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ summary: 'Create a point in a file' }) + @ApiBody({ type: CreatePointDto }) + async createPoint(@Body() body: CreatePointDto) { + return this.aiV2.created(await this.client.createPoint(body)); + } + + @Get('points/:pointId') + @ApiOperation({ summary: 'Get one point' }) + @ApiParam({ name: 'pointId' }) + async getPoint( + @Param('pointId') pointId: string, + @Query() query: GetPointQueryDto, + ) { + return this.aiV2.ok(await this.client.getPoint(pointId, query.with_vectors)); + } + + @Delete('points/:pointId') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Delete a point' }) + @ApiParam({ name: 'pointId' }) + async deletePoint(@Param('pointId') pointId: string) { + return this.aiV2.ok(await this.client.deletePoint(pointId)); + } + + @Put('points/:pointId') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Replace point content (version-guarded)' }) + @ApiParam({ name: 'pointId' }) + @ApiBody({ type: ReplacePointDto }) + async replacePoint( + @Param('pointId') pointId: string, + @Body() body: ReplacePointDto, + ) { + return this.aiV2.ok(await this.client.replacePoint(pointId, body)); + } + + @Patch('points/:pointId/payload') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Patch point payload (version-guarded)' }) + @ApiParam({ name: 'pointId' }) + @ApiBody({ type: PatchPointPayloadDto }) + async patchPointPayload( + @Param('pointId') pointId: string, + @Body() body: PatchPointPayloadDto, + ) { + return this.aiV2.ok(await this.client.patchPointPayload(pointId, body)); + } + + @Post('retrieval/query') + @ApiOperation({ summary: 'Hybrid semantic retrieval (not the ask API)' }) + @ApiBody({ type: RetrievalQueryDto }) + async queryRetrieval(@Body() body: RetrievalQueryDto) { + return this.aiV2.ok(await this.client.queryRetrieval(body)); + } +} diff --git a/src/ai-v2/ai-v2-ask.mapper.spec.ts b/src/ai-v2/ai-v2-ask.mapper.spec.ts new file mode 100644 index 0000000..3bf9037 --- /dev/null +++ b/src/ai-v2/ai-v2-ask.mapper.spec.ts @@ -0,0 +1,48 @@ +import { Sender } from '../common/types/sender.type'; +import { + isEscalationOffered, + resolveAskText, + resolveSessionId, + toAskCompatiblePayload, +} from './ai-v2-ask.mapper'; + +describe('ai-v2 ask mapper', () => { + it('reads question only', () => { + expect(resolveAskText({ question: 'الف' })).toBe('الف'); + expect(resolveAskText({ question: ' ' })).toBe(''); + }); + + it('treats empty sessionId as a new session', () => { + expect(resolveSessionId('')).toBeUndefined(); + expect(resolveSessionId(' ')).toBeUndefined(); + expect(resolveSessionId('64f1a2b3c4d5e6f7a8b9c0d1')).toBe( + '64f1a2b3c4d5e6f7a8b9c0d1', + ); + }); + + it('detects escalation_offered', () => { + expect(isEscalationOffered({ status: 'escalation_offered' })).toBe(true); + expect(isEscalationOffered({ status: 'answered' })).toBe(false); + }); + + it('keeps our messageId and stores AI runId separately', () => { + const payload = toAskCompatiblePayload({ + session: { + _id: '64f1a2b3c4d5e6f7a8b9c0d1', + messages: [{ sender: Sender.User }, { sender: Sender.Bot }], + }, + now: Date.now() / 1000, + question: 'چقدر پوشش بستری؟', + answer: 'با کارشناس تماس بگیرید', + messageId: '64f1a2b3c4d5e6f7a8b9c0d2', + runId: 'bea53a8d-3745-420f-a50d-fb700de5611d', + status: 'escalation_offered', + escalation: { summary: 'not in docs', handoff_context: {} }, + isNewSession: true, + }); + + expect(payload.messageId).toBe('64f1a2b3c4d5e6f7a8b9c0d2'); + expect(payload.runId).toBe('bea53a8d-3745-420f-a50d-fb700de5611d'); + expect(payload.offerOnlineChat).toBe(true); + }); +}); diff --git a/src/ai-v2/ai-v2-ask.mapper.ts b/src/ai-v2/ai-v2-ask.mapper.ts new file mode 100644 index 0000000..b38acd3 --- /dev/null +++ b/src/ai-v2/ai-v2-ask.mapper.ts @@ -0,0 +1,118 @@ +import { HttpStatus } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Sender } from 'src/common/types/sender.type'; +import { AiV2RunResult } from './ai-v2.client'; + +export const AI_UNAVAILABLE_FALLBACK = + 'به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. '; + +export type AskCompatiblePayload = { + sessionId: string; + count: string; + date?: string; + time?: string; + createdAt?: [string, string]; + question: string; + answer: string; + history: unknown[]; + messageId: string; + runId: string | null; + status: 'answered' | 'escalation_offered' | 'ai_unavailable'; + offerOnlineChat: boolean; + escalation: AiV2RunResult['escalation']; +}; + +export function resolveAskText(body: { question?: string }): string { + return String(body.question || '').trim(); +} + +export function resolveSessionId(sessionId?: string): string | undefined { + const trimmed = String(sessionId || '').trim(); + return trimmed || undefined; +} + +export function isEscalationOffered(result: Pick): boolean { + return result.status === 'escalation_offered'; +} + +export function buildUserMessage(text: string, now: number) { + return { + messageId: new Types.ObjectId(), + text, + sender: Sender.User, + react: 'Nothing', + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }; +} + +export function buildBotMessage(params: { + text: string; + now: number; + runId?: string | null; + status?: AskCompatiblePayload['status']; + escalation?: AiV2RunResult['escalation']; +}) { + return { + messageId: new Types.ObjectId(), + text: params.text, + sender: Sender.Bot, + react: null, + runId: params.runId || undefined, + createdAt: TimeHelper.unix2PersianTimeAndDate(params.now), + createdISO: Date.now(), + aiStatus: params.status, + escalation: params.escalation || undefined, + }; +} + +export function toAskCompatiblePayload(params: { + session: { + _id: unknown; + messages: Array<{ sender: string; messageId?: unknown; runId?: string }>; + createdAt?: [string, string]; + }; + now: number; + question: string; + answer: string; + messageId: string; + runId?: string | null; + status: AskCompatiblePayload['status']; + escalation?: AiV2RunResult['escalation']; + isNewSession: boolean; +}): AskCompatiblePayload { + const botCount = params.session.messages.filter( + (message) => message.sender === Sender.Bot, + ).length; + const persian = TimeHelper.unix2PersianTimeAndDate(params.now); + const offerOnlineChat = + params.status === 'escalation_offered' || params.status === 'ai_unavailable'; + + const payload: AskCompatiblePayload = { + sessionId: String(params.session._id), + count: `${botCount}/20`, + question: params.question, + answer: params.answer, + history: params.session.messages, + messageId: params.messageId, + runId: params.runId ?? null, + status: params.status, + offerOnlineChat, + escalation: params.escalation ?? null, + }; + + if (params.isNewSession) { + payload.date = persian[1]; + payload.time = persian[0]; + } else { + payload.createdAt = persian as [string, string]; + } + + return payload; +} + +export function wrapAskResponse(data: AskCompatiblePayload) { + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); +} diff --git a/src/ai-v2/ai-v2-threads.controller.ts b/src/ai-v2/ai-v2-threads.controller.ts new file mode 100644 index 0000000..fe522d4 --- /dev/null +++ b/src/ai-v2/ai-v2-threads.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Post, Req, Res } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { AiV2Service } from './ai-v2.service'; +import { CreateFeedbackDto, CreateRunDto } from './dto/threads.dto'; + +@ApiTags('v2 AI') +@ApiBearerAuth() +@Controller('ai/v2/threads') +export class AiV2ThreadsController { + constructor(private readonly aiV2: AiV2Service) {} + + @Post('runs') + @ApiOperation({ + summary: 'Ask the new AI (same shape as /user/ask)', + description: + 'Body: `{ question, sessionId? }`. Empty/omitted sessionId starts a new session. ' + + 'We generate our own messageId and store the AI run_id on the bot message. ' + + 'When AI_V2_STREAM=true, emits SSE `meta` → `token*` → `result`.', + }) + @ApiBody({ type: CreateRunDto }) + @ApiProduces('application/json', 'text/event-stream') + @ApiResponse({ + status: 200, + description: 'Ask-compatible JSON, or SSE when AI_V2_STREAM=true.', + }) + async createRun( + @Body() body: CreateRunDto, + @CurrentIdentity() user: any, + @Req() req: Request, + @Res() res: Response, + ) { + try { + await this.aiV2.run(body, user, req, res); + } catch (err) { + if (res.headersSent) { + const message = err?.message || 'ai_v2_run_failed'; + const code = err?.getStatus?.() || 502; + res.write( + `event: error\ndata: ${JSON.stringify({ code, message })}\n\n`, + ); + res.end(); + return; + } + throw err; + } + } + + @Post('feedback') + @ApiOperation({ + summary: 'Thumbs up/down for a bot answer', + description: + '`helpful: true` stores Like, `false` stores Dislike on the message. ' + + 'The same boolean is posted to the AI as run feedback using the stored `runId`.', + }) + @ApiBody({ type: CreateFeedbackDto }) + async createFeedback( + @Body() body: CreateFeedbackDto, + @CurrentIdentity() user: any, + ) { + return this.aiV2.feedback(body, user); + } +} diff --git a/src/ai-v2/ai-v2.client.ts b/src/ai-v2/ai-v2.client.ts new file mode 100644 index 0000000..ec8d10a --- /dev/null +++ b/src/ai-v2/ai-v2.client.ts @@ -0,0 +1,407 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; +import * as FormData from 'form-data'; +import { Readable } from 'node:stream'; +import { AiV2Exception } from './ai-v2.exception'; +import { iterateSseEvents, readStreamToString } from './sse'; + +export type AiV2RunResult = { + run_id: string; + status: 'answered' | 'escalation_offered'; + message: string; + escalation: { summary?: string; handoff_context?: Record } | null; +}; + +@Injectable() +export class AiV2Client { + private baseUrl(): string { + const url = process.env.AI_V2_BASE_URL?.trim(); + if (!url) { + throw new AiV2Exception( + HttpStatus.SERVICE_UNAVAILABLE, + 'ai_v2_base_url_missing', + ); + } + return url.replace(/\/$/, ''); + } + + private apiKey(): string { + const key = process.env.AI_V2_API_KEY?.trim(); + if (!key) { + throw new AiV2Exception( + HttpStatus.SERVICE_UNAVAILABLE, + 'ai_v2_api_key_missing', + ); + } + return key; + } + + timeoutMs(): number { + const raw = process.env.AI_V2_TIMEOUT || process.env.AI_SERVICE_TIMEOUT; + const parsed = parseInt(String(raw || '75000'), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 75_000; + } + + isStreamToFrontendEnabled(): boolean { + const raw = process.env.AI_V2_STREAM?.trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; + } + + private headers(extra: Record = {}): Record { + return { + Authorization: `Bearer ${this.apiKey()}`, + accept: 'application/json', + ...extra, + }; + } + + private qs(params?: object): Record { + const out: Record = {}; + if (!params) return out; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + out[key] = value; + } + return out; + } + + private throwFromAxios(err: unknown): never { + if (err instanceof AiV2Exception) throw err; + const axiosErr = err as AxiosError; + if (axiosErr?.isAxiosError) { + const status = + axiosErr.response?.status || HttpStatus.BAD_GATEWAY; + const data = axiosErr.response?.data ?? axiosErr.message; + const message = + (data as any)?.error?.message || + (data as any)?.message || + 'ai_v2_request_failed'; + throw new AiV2Exception(status, message, data); + } + throw new AiV2Exception( + HttpStatus.BAD_GATEWAY, + (err as Error)?.message || 'ai_v2_request_failed', + ); + } + + private async request(config: AxiosRequestConfig): Promise { + try { + const response = await axios.request({ + timeout: this.timeoutMs(), + maxBodyLength: Infinity, + ...config, + baseURL: this.baseUrl(), + headers: this.headers(config.headers as Record), + }); + return response.data; + } catch (err) { + this.throwFromAxios(err); + } + } + + listDomains(includeDisabled?: boolean) { + return this.request({ + method: 'GET', + url: '/api/v1/domains', + params: this.qs({ include_disabled: includeDisabled }), + }); + } + + createDomain(body: object) { + return this.request({ + method: 'POST', + url: '/api/v1/domains', + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + updateDomain(domain: string, body: object) { + return this.request({ + method: 'PATCH', + url: `/api/v1/domains/${encodeURIComponent(domain)}`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + disableDomain(domain: string) { + return this.request({ + method: 'DELETE', + url: `/api/v1/domains/${encodeURIComponent(domain)}`, + }); + } + + enableDomain(domain: string) { + return this.request({ + method: 'POST', + url: `/api/v1/domains/${encodeURIComponent(domain)}/enable`, + }); + } + + listDomainPoints(domain: string, query: object) { + return this.request({ + method: 'GET', + url: `/api/v1/domains/${encodeURIComponent(domain)}/points`, + params: this.qs(query), + }); + } + + async uploadFile(file: Express.Multer.File, domain: string) { + const form = new FormData(); + form.append('file', file.buffer, { + filename: file.originalname, + contentType: file.mimetype || 'application/octet-stream', + }); + form.append('domain', domain); + return this.request({ + method: 'POST', + url: '/api/v1/files', + data: form, + headers: form.getHeaders() as Record, + timeout: Math.max(this.timeoutMs(), 120_000), + }); + } + + listFiles(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/files', + params: this.qs(query), + }); + } + + getFile(fileId: string) { + return this.request({ + method: 'GET', + url: `/api/v1/files/${encodeURIComponent(fileId)}`, + }); + } + + deleteFile(fileId: string) { + return this.request({ + method: 'DELETE', + url: `/api/v1/files/${encodeURIComponent(fileId)}`, + }); + } + + listFilePoints(fileId: string, query: object) { + return this.request({ + method: 'GET', + url: `/api/v1/files/${encodeURIComponent(fileId)}/points`, + params: this.qs(query), + }); + } + + countPoints(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/points/count', + params: this.qs(query), + }); + } + + searchPoints(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/points/search', + params: this.qs(query), + }); + } + + listPoints(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/points', + params: this.qs(query), + }); + } + + createPoint(body: object) { + return this.request({ + method: 'POST', + url: '/api/v1/points', + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + getPoint(pointId: string, withVectors?: boolean) { + return this.request({ + method: 'GET', + url: `/api/v1/points/${encodeURIComponent(pointId)}`, + params: this.qs({ with_vectors: withVectors }), + }); + } + + deletePoint(pointId: string) { + return this.request({ + method: 'DELETE', + url: `/api/v1/points/${encodeURIComponent(pointId)}`, + }); + } + + replacePoint(pointId: string, body: object) { + return this.request({ + method: 'PUT', + url: `/api/v1/points/${encodeURIComponent(pointId)}`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + patchPointPayload(pointId: string, body: object) { + return this.request({ + method: 'PATCH', + url: `/api/v1/points/${encodeURIComponent(pointId)}/payload`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + queryRetrieval(body: object) { + return this.request({ + method: 'POST', + url: '/api/v1/retrieval/query', + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + createFeedback( + threadId: string, + runId: string, + body: object, + ) { + return this.request({ + method: 'POST', + url: `/api/v1/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + private async openRunStream( + threadId: string, + body: { message: string; user_id: string }, + ): Promise> { + try { + const response = await axios.request({ + method: 'POST', + baseURL: this.baseUrl(), + url: `/api/v1/threads/${encodeURIComponent(threadId)}/runs`, + data: body, + headers: this.headers({ + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }), + responseType: 'stream', + timeout: this.timeoutMs(), + validateStatus: () => true, + maxBodyLength: Infinity, + }); + return response; + } catch (err) { + this.throwFromAxios(err); + } + } + + private async rejectStreamResponse(response: AxiosResponse): Promise { + const raw = await readStreamToString(response.data); + let data: unknown = raw; + try { + data = raw ? JSON.parse(raw) : raw; + } catch { + data = raw; + } + const message = + (data as any)?.error?.message || + (data as any)?.message || + 'ai_v2_run_failed'; + throw new AiV2Exception(response.status, message, data); + } + + async consumeRun( + threadId: string, + body: { message: string; user_id: string }, + handlers?: { + onToken?: (text: string) => void; + onClientClose?: (abort: () => void) => void; + }, + ): Promise { + const response = await this.openRunStream(threadId, body); + if (response.status >= 400) { + await this.rejectStreamResponse(response); + } + + const upstream = response.data; + const abort = () => { + if (typeof (upstream as any).destroy === 'function') { + (upstream as any).destroy(); + } + }; + handlers?.onClientClose?.(abort); + + let result: AiV2RunResult | null = null; + for await (const event of iterateSseEvents(upstream)) { + if (event.event === 'token') { + const text = (event.data as any)?.text; + if (typeof text === 'string' && text.length) { + handlers?.onToken?.(text); + } + continue; + } + if (event.event === 'error') { + const message = + (event.data as any)?.message || 'ai_v2_run_failed'; + throw new AiV2Exception(HttpStatus.BAD_GATEWAY, message, event.data); + } + if (event.event === 'result' && event.data && typeof event.data === 'object') { + result = event.data as AiV2RunResult; + } + } + + if (!result) { + throw new AiV2Exception( + HttpStatus.BAD_GATEWAY, + 'ai_v2_run_missing_result', + ); + } + return result; + } + + async collectRun( + threadId: string, + body: { message: string; user_id: string }, + ): Promise { + return this.consumeRun(threadId, body); + } + + async pipeRun( + threadId: string, + body: { message: string; user_id: string }, + dest: NodeJS.WritableStream, + onClientClose?: (abort: () => void) => void, + ): Promise { + const response = await this.openRunStream(threadId, body); + if (response.status >= 400) { + await this.rejectStreamResponse(response); + } + + const upstream = response.data; + const abort = () => { + if (typeof (upstream as any).destroy === 'function') { + (upstream as any).destroy(); + } + }; + onClientClose?.(abort); + + await new Promise((resolve, reject) => { + upstream.on('error', reject); + dest.on('error', reject); + dest.on('close', abort); + upstream.on('end', () => resolve()); + upstream.pipe(dest, { end: true }); + }); + } +} diff --git a/src/ai-v2/ai-v2.exception.ts b/src/ai-v2/ai-v2.exception.ts new file mode 100644 index 0000000..61f9383 --- /dev/null +++ b/src/ai-v2/ai-v2.exception.ts @@ -0,0 +1,11 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class AiV2Exception extends HttpException { + constructor( + status: number = HttpStatus.BAD_GATEWAY, + message = 'ai_v2_error', + data: unknown = null, + ) { + super({ statusCode: status, message, data }, status); + } +} diff --git a/src/ai-v2/ai-v2.module.ts b/src/ai-v2/ai-v2.module.ts new file mode 100644 index 0000000..146c377 --- /dev/null +++ b/src/ai-v2/ai-v2.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { AiV2AdminController } from './ai-v2-admin.controller'; +import { AiV2ThreadsController } from './ai-v2-threads.controller'; +import { AiV2Client } from './ai-v2.client'; +import { AiV2Service } from './ai-v2.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [AiV2AdminController, AiV2ThreadsController], + providers: [AiV2Client, AiV2Service], + exports: [AiV2Client, AiV2Service], +}) +export class AiV2Module {} diff --git a/src/ai-v2/ai-v2.service.ts b/src/ai-v2/ai-v2.service.ts new file mode 100644 index 0000000..178770a --- /dev/null +++ b/src/ai-v2/ai-v2.service.ts @@ -0,0 +1,326 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Request, Response } from 'express'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { ReactEnum } from 'src/common/types/react.type'; +import { Sender } from 'src/common/types/sender.type'; +import { ReactsModel } from 'src/database/model/botReact.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { + AI_UNAVAILABLE_FALLBACK, + AskCompatiblePayload, + buildBotMessage, + buildUserMessage, + isEscalationOffered, + resolveAskText, + resolveSessionId, + toAskCompatiblePayload, + wrapAskResponse, +} from './ai-v2-ask.mapper'; +import { AiV2Client, AiV2RunResult } from './ai-v2.client'; +import { AiV2Exception } from './ai-v2.exception'; +import { CreateFeedbackDto, CreateRunDto } from './dto/threads.dto'; + +@Injectable() +export class AiV2Service { + constructor( + private readonly client: AiV2Client, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(ReactsModel.name) + private readonly reacts: Model, + ) {} + + identityUserId(user: any): string { + const id = user?._id ?? user?.userData?._id ?? user?.id; + if (!id) { + throw new AiV2Exception(HttpStatus.UNAUTHORIZED, 'user_id_required'); + } + return String(id); + } + + private userObjectId(user: any): Types.ObjectId { + return new Types.ObjectId(this.identityUserId(user)); + } + + async run(body: CreateRunDto, user: any, req: Request, res: Response) { + console.log('run service called with body:', body); + const question = resolveAskText(body); + if (!question) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'question_required'); + } + + const now = Date.now() / 1000; + const sessionId = resolveSessionId(body.sessionId); + const isNewSession = !sessionId; + const session = isNewSession + ? await this.createSession(question, user, now) + : await this.appendUserTurn(sessionId, question, user, now); + + const stream = this.client.isStreamToFrontendEnabled(); + if (stream) { + this.beginSse(res); + this.writeSse(res, 'meta', { + sessionId: String(session._id), + isNewSession, + }); + } + + let result: AiV2RunResult | null = null; + let status: AskCompatiblePayload['status'] = 'answered'; + let answer = AI_UNAVAILABLE_FALLBACK; + let runId: string | null = null; + + try { + result = await this.client.consumeRun( + String(session._id), + { message: question, user_id: this.identityUserId(user) }, + { + onToken: stream + ? (text) => this.writeSse(res, 'token', { text }) + : undefined, + onClientClose: (abort) => { + res.on('close', () => { + if (!res.writableEnded) abort(); + }); + }, + }, + ); + answer = result.message; + status = isEscalationOffered(result) + ? 'escalation_offered' + : 'answered'; + runId = result.run_id; + } catch (err) { + if ( + err instanceof HttpException && + err.getStatus() < 500 && + err.getStatus() !== HttpStatus.BAD_GATEWAY + ) { + throw err; + } + status = 'ai_unavailable'; + } + + const sessionObjectId = new Types.ObjectId(String(session._id)); + const botMessage = buildBotMessage({ + text: answer, + now, + runId, + status, + escalation: result?.escalation, + }); + await this.session.updateOne( + { _id: sessionObjectId }, + { $push: { messages: botMessage } }, + ); + + const updated = await this.session.findById(sessionObjectId).exec(); + if (!updated) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + const botCount = updated.messages.filter( + (message) => message.sender === Sender.Bot, + ).length; + const sessionPatch: Record = {}; + if (botCount >= 20) sessionPatch.chatClosed = true; + if (status === 'escalation_offered') sessionPatch.escalationOffered = true; + if (Object.keys(sessionPatch).length) { + await this.session.updateOne( + { _id: sessionObjectId }, + { $set: sessionPatch }, + ); + if (sessionPatch.chatClosed) (updated as any).chatClosed = true; + if (sessionPatch.escalationOffered) { + (updated as any).escalationOffered = true; + } + } + + const payload = toAskCompatiblePayload({ + session: updated, + now, + question, + answer, + messageId: String(botMessage.messageId), + runId, + status, + escalation: result?.escalation, + isNewSession, + }); + + if (stream) { + this.writeSse(res, 'result', payload); + res.end(); + return; + } + + res.status(HttpStatus.OK).json(wrapAskResponse(payload)); + } + + async feedback(body: CreateFeedbackDto, user: any) { + const sessionId = resolveSessionId(body.sessionId); + if (!sessionId || !Types.ObjectId.isValid(sessionId)) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'invalid_session_id'); + } + if (!Types.ObjectId.isValid(body.messageId)) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'invalid_message_id'); + } + + const sessionObjectId = new Types.ObjectId(sessionId); + const messageObjectId = new Types.ObjectId(body.messageId); + const userObjectId = this.userObjectId(user); + const react = body.helpful ? ReactEnum.like : ReactEnum.dislike; + + const session = await this.session + .findOne({ + _id: sessionObjectId, + userId: userObjectId, + }) + .exec(); + if (!session) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + const botMessageIndex = session.messages.findIndex( + (message) => String(message.messageId) === String(body.messageId), + ); + if (botMessageIndex < 0) { + throw new HttpException('message_not_found', HttpStatus.NOT_FOUND); + } + + const botMessage = session.messages[botMessageIndex]; + if (botMessage.runId) { + await this.client.createFeedback(String(session._id), botMessage.runId, { + helpful: body.helpful, + user_id: this.identityUserId(user), + comment: body.comment ?? null, + }); + } + + await this.session.updateOne( + { + _id: sessionObjectId, + 'messages.messageId': messageObjectId, + }, + { + $set: { 'messages.$.react': react }, + }, + ); + + let questionText: string | null = null; + for (let i = botMessageIndex - 1; i >= 0; i--) { + if (session.messages[i].sender === Sender.User) { + questionText = session.messages[i].text; + break; + } + } + + await this.reacts.findOneAndUpdate( + { + sessionId: sessionObjectId, + messageId: messageObjectId, + userId: userObjectId, + }, + { + $set: { + question: questionText, + answer: botMessage.text, + sender: botMessage.sender, + react, + createdAt: TimeHelper.unix2PersianTimeAndDate(Date.now() / 1000), + createdISO: new Date(), + }, + }, + { upsert: true, new: true }, + ); + + const updated = await this.session.findById(sessionObjectId).exec(); + if (!updated) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + return this.ok({ + sessionId: updated._id, + messageId: String(body.messageId), + runId: botMessage.runId || null, + helpful: body.helpful, + react, + messages: updated.messages, + }); + } + + ok(data: T, status: HttpStatus = HttpStatus.OK, message = 'SUCCESS') { + return new BaseResponseDTO(status, message, data); + } + + created(data: T) { + return new BaseResponseDTO(HttpStatus.CREATED, 'SUCCESS', data); + } + + private async createSession(question: string, user: any, now: number) { + return this.session.create({ + _id: new Types.ObjectId(), + userId: this.userObjectId(user), + chatTitle: question, + connectedToExpert: false, + onlineChatClosed: false, + chatClosed: false, + escalationOffered: false, + expert: '', + expertRate: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + messages: [buildUserMessage(question, now)], + }); + } + + private async appendUserTurn( + sessionId: string, + question: string, + user: any, + now: number, + ) { + if (!Types.ObjectId.isValid(sessionId)) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'invalid_session_id'); + } + const session = await this.session + .findOne({ + _id: new Types.ObjectId(sessionId), + userId: this.userObjectId(user), + }) + .exec(); + + if (!session) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + if (session.chatClosed) { + throw new HttpException('session_limit_reached', HttpStatus.BAD_REQUEST); + } + + await this.session.updateOne( + { _id: session._id }, + { $push: { messages: buildUserMessage(question, now) } }, + ); + return session; + } + + private beginSse(res: Response) { + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof (res as any).flushHeaders === 'function') { + (res as any).flushHeaders(); + } + res.socket?.setNoDelay?.(true); + } + + private writeSse(res: Response, event: string, data: unknown) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + if (typeof (res as any).flush === 'function') { + (res as any).flush(); + } + } +} diff --git a/src/ai-v2/dto/common-query.dto.ts b/src/ai-v2/dto/common-query.dto.ts new file mode 100644 index 0000000..6e60fb0 --- /dev/null +++ b/src/ai-v2/dto/common-query.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export function toOptionalBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (value === true || value === 'true' || value === '1') return true; + if (value === false || value === 'false' || value === '0') return false; + return undefined; +} + +export class CursorPageQueryDto { + @ApiPropertyOptional({ default: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_inactive?: boolean; +} diff --git a/src/ai-v2/dto/domains.dto.ts b/src/ai-v2/dto/domains.dto.ts new file mode 100644 index 0000000..56eb87f --- /dev/null +++ b/src/ai-v2/dto/domains.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; +import { toOptionalBoolean } from './common-query.dto'; + +export class ListDomainsQueryDto { + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_disabled?: boolean; +} + +export class CreateDomainDto { + @ApiProperty({ + example: 'faq', + description: 'Immutable domain key (like a collection name).', + }) + @IsString() + @MinLength(1) + @MaxLength(80) + @Matches(/^[a-z0-9][a-z0-9_-]*$/) + domain: string; + + @ApiProperty({ example: 'FAQ' }) + @IsString() + @MinLength(1) + @MaxLength(200) + display_name: string; + + @ApiPropertyOptional({ type: 'object', additionalProperties: true }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateDomainDto { + @ApiProperty({ example: 'Frequently asked questions' }) + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(200) + display_name: string; +} diff --git a/src/ai-v2/dto/files.dto.ts b/src/ai-v2/dto/files.dto.ts new file mode 100644 index 0000000..41f3983 --- /dev/null +++ b/src/ai-v2/dto/files.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { CursorPageQueryDto, toOptionalBoolean } from './common-query.dto'; + +export class ListFilesQueryDto extends CursorPageQueryDto { + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_deleted?: boolean; +} diff --git a/src/ai-v2/dto/points.dto.ts b/src/ai-v2/dto/points.dto.ts new file mode 100644 index 0000000..a07df3b --- /dev/null +++ b/src/ai-v2/dto/points.dto.ts @@ -0,0 +1,116 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Min, + MinLength, +} from 'class-validator'; +import { CursorPageQueryDto, toOptionalBoolean } from './common-query.dto'; + +export class ListPointsQueryDto extends CursorPageQueryDto { + @ApiProperty({ example: '0e8fdea8-d778-4e0d-9fc5-82326a840657' }) + @IsString() + @IsNotEmpty() + file_id: string; +} + +export class CountPointsQueryDto { + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + file_id?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_inactive?: boolean; +} + +export class SearchPointsQueryDto extends CursorPageQueryDto { + @ApiProperty({ example: 'بیمه تکمیلی' }) + @IsString() + @IsNotEmpty() + q: string; + + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + file_id?: string; +} + +export class GetPointQueryDto { + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + with_vectors?: boolean; +} + +export class CreatePointDto { + @ApiProperty({ format: 'uuid' }) + @IsString() + @IsNotEmpty() + file_id: string; + + @ApiProperty() + @IsString() + @MinLength(1) + content: string; + + @ApiPropertyOptional({ default: 'paragraph' }) + @IsOptional() + @IsString() + content_type?: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Place the new point after this one. Omit to insert at start.', + }) + @IsOptional() + @IsString() + after_point_id?: string; +} + +export class ReplacePointDto { + @ApiProperty() + @IsString() + @MinLength(1) + content: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + content_type?: string; + + @ApiProperty({ description: 'Expected current version (optimistic lock).' }) + @IsInt() + @Min(1) + version: number; +} + +export class PatchPointPayloadDto { + @ApiProperty({ type: 'object', additionalProperties: true }) + @IsObject() + payload: Record; + + @ApiProperty({ description: 'Expected current version (optimistic lock).' }) + @IsInt() + @Min(1) + version: number; +} diff --git a/src/ai-v2/dto/retrieval.dto.ts b/src/ai-v2/dto/retrieval.dto.ts new file mode 100644 index 0000000..3e38e48 --- /dev/null +++ b/src/ai-v2/dto/retrieval.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator'; + +export class RetrievalQueryDto { + @ApiProperty({ example: 'مدارک بستری' }) + @IsString() + @MinLength(1) + query: string; + + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string | null; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsString() + file_id?: string | null; + + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + limit?: number | null; + + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + prefetch_limit?: number | null; +} diff --git a/src/ai-v2/dto/threads.dto.ts b/src/ai-v2/dto/threads.dto.ts new file mode 100644 index 0000000..ec2b4d9 --- /dev/null +++ b/src/ai-v2/dto/threads.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateRunDto { + @ApiProperty({ + example: 'مدارک لازم برای بستری چیست؟', + description: 'User question. Same field as /user/ask.', + }) + @IsString() + @MinLength(1) + @MaxLength(4000) + question: string; + + @ApiPropertyOptional({ + description: + 'Existing chat session id. Omit or leave empty on the first question.', + example: '', + }) + @IsOptional() + @IsString() + sessionId?: string; +} + +export class CreateFeedbackDto { + @ApiProperty({ + example: '64f1a2b3c4d5e6f7a8b9c0d1', + description: 'Chat session id (also the AI thread id).', + }) + @IsString() + @MinLength(1) + sessionId: string; + + @ApiProperty({ + example: '64f1a2b3c4d5e6f7a8b9c0d2', + description: 'Our bot messageId. Backend looks up the stored AI runId.', + }) + @IsString() + @MinLength(1) + messageId: string; + + @ApiProperty({ + example: true, + description: 'true = Like, false = Dislike. Also sent to the AI as `helpful`.', + }) + @IsBoolean() + helpful: boolean; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + comment?: string | null; +} diff --git a/src/ai-v2/sse.spec.ts b/src/ai-v2/sse.spec.ts new file mode 100644 index 0000000..ce9f412 --- /dev/null +++ b/src/ai-v2/sse.spec.ts @@ -0,0 +1,27 @@ +import { Readable } from 'node:stream'; +import { collectSseEvents, parseSseBlock } from './sse'; + +describe('ai-v2 sse', () => { + it('parses token and result blocks', () => { + const token = parseSseBlock('event: token\ndata: {"text":"سلام"}'); + expect(token).toEqual({ + event: 'token', + data: { text: 'سلام' }, + rawData: '{"text":"سلام"}', + }); + + const result = parseSseBlock( + 'event: result\ndata: {"run_id":"abc","status":"answered","message":"hi","escalation":null}', + ); + expect(result?.event).toBe('result'); + expect((result?.data as any).message).toBe('hi'); + }); + + it('collects a streamed run', async () => { + const payload = + 'event: token\ndata: {"text":"سلام"}\n\n' + + 'event: result\ndata: {"run_id":"1","status":"answered","message":"سلام","escalation":null}\n\n'; + const events = await collectSseEvents(Readable.from([payload])); + expect(events.map((e) => e.event)).toEqual(['token', 'result']); + }); +}); diff --git a/src/ai-v2/sse.ts b/src/ai-v2/sse.ts new file mode 100644 index 0000000..5495dee --- /dev/null +++ b/src/ai-v2/sse.ts @@ -0,0 +1,73 @@ +import { Readable } from 'node:stream'; + +export type SseEvent = { + event: string; + data: unknown; + rawData: string; +}; + +export function parseSseBlock(block: string): SseEvent | null { + const lines = block.split('\n'); + let event = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (!line || line.startsWith(':')) continue; + if (line.startsWith('event:')) { + event = line.slice(6).trim(); + continue; + } + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (dataLines.length === 0) return null; + const rawData = dataLines.join('\n'); + let data: unknown = rawData; + try { + data = JSON.parse(rawData); + } catch { + data = rawData; + } + return { event, data, rawData }; +} + +export async function readStreamToString(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +export async function* iterateSseEvents( + stream: Readable, +): AsyncGenerator { + let buffer = ''; + + for await (const chunk of stream) { + buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + const parts = buffer.split(/\r?\n\r?\n/); + buffer = parts.pop() ?? ''; + for (const part of parts) { + const parsed = parseSseBlock(part); + if (parsed) yield parsed; + } + } + + if (buffer.trim()) { + const parsed = parseSseBlock(buffer); + if (parsed) yield parsed; + } +} + +export async function collectSseEvents( + stream: Readable, +): Promise { + const events: SseEvent[] = []; + for await (const event of iterateSseEvents(stream)) { + events.push(event); + } + return events; +} diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts new file mode 100644 index 0000000..f85add8 --- /dev/null +++ b/src/api/admin/admin.controller.ts @@ -0,0 +1,91 @@ +import { Controller, Get, Body, Patch, UseGuards, Param, Post, Req, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { Role } from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { AclService } from 'src/acl/acl.service'; +import { EditProfileDto } from './dto/edit-profile.dto'; +import { AdminService } from './admin.service'; +import { createExpertDto } from './dto/create-new-expert.dto'; + +@Controller('admin') +@UseGuards(AdminGuard) +@ApiBearerAuth() +export class AdminController { + constructor( + private readonly adminService: AdminService, + private readonly aclService: AclService, + ) {} + + @Get('profile') + @Permissions(Permission.ProfileRead) + getProfile(@AdminIdentity() adminIdentity: AdminModel) { + return this.adminService.getProfile(adminIdentity); + } + + @Post('newExpert') + @Permissions(Permission.StaffCreateExpert) + async addNewExpert( + @Body() body: createExpertDto, + @AdminIdentity() adminIdentity: any, + @Req() req: any, + ) { + const created = await this.aclService.createStaff( + adminIdentity.userData._id, + { + email: body.email, + password: body.password, + mobile: body.mobile, + name: body.name, + family: body.family, + role: Role.Expert, + }, + req, + ); + return new BaseResponseDTO( + HttpStatus.CREATED, + 'Expert created successfully', + created, + ); + } + + @Patch('profile') + @Permissions(Permission.ProfileWrite) + editProfile( + @Body() body: EditProfileDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.adminService.modifyProfile(body, adminIdentity); + } + + @Patch('experts/:expertId/toggle-active') + @Permissions(Permission.StaffCreateExpert) + async toggleExpertActivation( + @Param('expertId') expertId: string, + @AdminIdentity() adminIdentity: any, + @Req() req: any, + ) { + const current = await this.adminService.findOneAdmin({ _id: expertId }); + const nextActive = !(current?.isActive); + return this.aclService.setStaffActive( + adminIdentity.userData._id, + expertId, + Boolean(nextActive), + req, + ); + } + + @Get('dashboard') + @Permissions(Permission.DashboardView) + @ApiOperation({ + summary: 'Get admin dashboard statistics', + description: 'Returns real-time statistics for the admin dashboard including: experts in conversation, online experts, users chatting with expert, waiting users, total chatbot users, and insurance holder users. Data is fetched from Redis for real-time accuracy.', + }) + getDashboard() { + return this.adminService.getDashboard(); + } +} diff --git a/src/api/admin/admin.module.ts b/src/api/admin/admin.module.ts new file mode 100644 index 0000000..96b8ee4 --- /dev/null +++ b/src/api/admin/admin.module.ts @@ -0,0 +1,22 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { UploadModule } from 'src/upload/upload.module'; +import { SupportManagementModule } from 'src/socket/support-management/support-management.module'; +import { AclModule } from 'src/acl/acl.module'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +@Module({ + imports: [ + DatabaseModule, + UploadModule, + AclModule, + ...(process.env.SUPPORT_MANAGEMENT === 'true' + ? [forwardRef(() => SupportManagementModule)] + : []), + ], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts new file mode 100644 index 0000000..faa7f3d --- /dev/null +++ b/src/api/admin/admin.service.ts @@ -0,0 +1,333 @@ +import { HttpException, HttpStatus, Injectable, Optional, Inject } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { FilterQuery, Model, Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Role } from 'src/common/types/role.type'; +import { AdminDocument, AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { ChatService } from 'src/socket/support-management/chat.service'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { CreateAdminDto } from './dto/create-admin.dto'; +import { EditProfileDto } from './dto/edit-profile.dto'; +import { createExpertDto } from './dto/create-new-expert.dto'; +import * as crypto from 'node:crypto'; + +@Injectable() +export class AdminService { + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(SessionModel.name) + private readonly sessionModel: Model, + @InjectModel(UserModel.name) + private readonly userModel: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogsModel: Model, + @Optional() @Inject(ChatService) + private readonly chatService: ChatService | null, + private readonly redisService: RedisService, + ) {} + + async create(createAdminDto: CreateAdminDto, role: Role.Admin | Role.Expert | Role.Supervisor) { + return await this.adminModel.create({ + ...createAdminDto, + username: createAdminDto.email, + role: role, + }); + } + + async findOneAdmin(query: FilterQuery): Promise { + return await this.adminModel.findOne(query); + } + + async getProfile(adminIdentity): Promise { + try { + const admin = await this.adminModel + .findOne( + { + _id: new Types.ObjectId(adminIdentity.userData._id), + isActive: true, + }, + { password: 0, resetToken: 0 }, + ) + .populate('avatar'); + + if (!admin) throw new HttpException('not_found', HttpStatus.NOT_FOUND); + + // Use expert's email to find sessions (expert field stores email) + const expertEmail = admin.email; + + // Calculate total answered: sessions where expert actually connected (connectedToExpert === true) + // and the expert field matches this expert's email + const answeredSessions = await this.sessionModel.find({ + expert: expertEmail, + connectedToExpert: true, + onlineStartDate: { $exists: true }, + }); + + // Calculate activity time (sum of all answered session durations in seconds) + const activityTime = Math.round( + answeredSessions.reduce((total, session) => { + if (session.onlineStartDate && session.onlineEndDate) { + const duration = + (session.onlineEndDate.getTime() - + session.onlineStartDate.getTime()) / + 1000; + // Only add positive durations to avoid negative activity time + return total + (duration > 0 ? duration : 0); + } + return total; + }, 0), + ); + + // Calculate total answered (sessions where expert connected) + const totalAnswered = answeredSessions.length; + + // Calculate total not answered: count from reassigned_logs where this expert missed the chat + // (reassigned from them, meaning they were online but didn't answer) + const totalNotAnswered = await this.reassignedLogsModel.countDocuments({ + from: expertEmail, + }); + + // Calculate total sessions (answered + not answered) + const totalSessions = totalAnswered + totalNotAnswered; + + // Calculate total rate (average of all non-null rates from answered sessions) + const rates = answeredSessions + .filter( + (session) => + session.expertRate !== null && session.expertRate !== undefined, + ) + .map((session) => session.expertRate); + const totalRate = + rates.length > 0 + ? parseFloat((rates.reduce((sum, rate) => sum + rate, 0) / rates.length).toFixed(2)) + : 0; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: admin._id, + name: admin.name, + family: admin.family, + mobile: admin.mobile, + email: admin.email, + username: admin.username, + role: admin.role, + isActive: admin.isActive, + avatar: admin.avatar + ? `https://${process.env.BASEURL}/${(admin.avatar as any).filePath}` + : null, + birthDate: admin.birthDate, + reports: { + activityTime, + totalSessions, + totalAnswered, + totalNotAnswered, + totalRate, + currentNotActive: 0, // Will be implemented later + }, + }); + } catch (err) { + console.log(err); + throw new HttpException(err.message, HttpStatus.BAD_REQUEST); + } + } + + async createNewExpert(body:createExpertDto,adminIdentity:any){ + try { + const existingExpert = await this.adminModel.findOne({ // Explicitly type existingExpert as AdminModel | null + $or: [{ email: body.email }, { mobile: body.mobile }], + }); + + if (existingExpert) { + throw new HttpException('Expert with this email or mobile already exists', HttpStatus.BAD_REQUEST); + } + const hashedPassword = crypto.createHash('sha256').update(body.password).digest('hex'); + + const newExpert = await this.adminModel.create({ + email: body.email, + password: hashedPassword, + mobile: body.mobile, + name: body.name, + family: body.family, + role: 'expert', + username: body.email, // Set username to email + }); + + // Exclude password from the returned object + const { password, ...expertWithoutPassword } = newExpert.toObject(); + + return new BaseResponseDTO(HttpStatus.CREATED, 'Expert created successfully', expertWithoutPassword); + } catch (err) { + console.log(err); + throw new HttpException(err.message, HttpStatus.BAD_REQUEST); + } + } + + async modifyProfile(body: EditProfileDto, adminIdentity: any) { + const admin = await this.adminModel.findOne({ + _id: new Types.ObjectId(adminIdentity.userData._id), + isActive: true, + }); + + if (!admin) { + throw new HttpException('not_found', HttpStatus.NOT_FOUND); + } + + const updatePayload: any = { ...body }; + + const updatedUser = await this.adminModel + .findByIdAndUpdate(adminIdentity.userData._id, updatePayload, { + new: true, + projection: { + _id: 1, + role: 1, + mobile: 1, + email: 1, + username: 1, + name: 1, + family: 1, + createdAt: 1, + updatedAt: 1, + birthDate: 1, + birthday: 1, + avatar: 1, + }, + }) + .populate('avatar'); + + return updatedUser; + } + + async toggleExpertActivation(expertId: string): Promise { + try { + const expert = await this.adminModel.findOne({ + _id: new Types.ObjectId(expertId), + role: Role.Expert, + }); + + if (!expert) { + throw new HttpException('Expert not found', HttpStatus.NOT_FOUND); + } + + expert.isActive = !expert.isActive; + await expert.save(); + + const message = expert.isActive ? 'Expert activated successfully' : 'Expert deactivated successfully'; + const { password, ...expertWithoutPassword } = expert.toObject(); + return new BaseResponseDTO(HttpStatus.OK, message, expertWithoutPassword); + } catch (err) { + console.log(err); + throw new HttpException(err.message, HttpStatus.BAD_REQUEST); + } + } + + async getDashboard(): Promise { + try { + const redisClient = this.redisService.getClient(); + const ONLINE_EXPERTS_KEY = 'onlineExperts'; + const ROOMS_HASH_KEY = 'rooms:active'; + const WAITING_QUEUE_KEY = 'waitingQueue'; + const WAITING_USERS_KEY = 'waiting_users'; + + // Get all experts from Redis onlineExperts hash + let allExpertsFromRedis: any[] = []; + try { + const onlineExpertsEntries = await redisClient.hgetall(ONLINE_EXPERTS_KEY); + allExpertsFromRedis = Object.values(onlineExpertsEntries) + .map((v) => { + try { + return JSON.parse(v as string); + } catch { + return null; + } + }) + .filter((expert: any) => expert); + } catch (err) { + console.error('Error getting experts from Redis:', err); + } + + // 1. تعداد کارشناس در حال مکالمه - Number of experts currently in conversation + // Experts who have activeSessions > 0 + const expertsInConversation = allExpertsFromRedis.filter( + (expert: any) => expert.activeSessions > 0 + ); + const expertsInConversationCount = expertsInConversation.length; + + // 2. تعداد کارشناس آنلاین - Number of online experts + // Experts who are marked as online OR have active sessions (if in conversation, they should be online) + const onlineExpertIds = new Set(); + allExpertsFromRedis.forEach((expert: any) => { + if (expert.isOnline === true || expert.activeSessions > 0) { + onlineExpertIds.add(expert.expertId); + } + }); + const onlineExpertsCount = onlineExpertIds.size; + + // 3. تعداد کاربر در حال مکاتبه با کارشناس - Number of users currently chatting with expert + // Each room in rooms:active represents one active chat session + // Since each user can only have one active expert session, counting active rooms = counting unique users + let usersWithExpert = 0; + try { + if (this.chatService) { + const activeRooms = await this.chatService.getActiveRooms(); + usersWithExpert = activeRooms ? activeRooms.length : 0; + } else { + // Fallback: get directly from Redis if chatService is not available + const roomsEntries = await redisClient.hgetall(ROOMS_HASH_KEY); + const activeRooms = Object.values(roomsEntries) + .map((v) => { + try { + return JSON.parse(v as string); + } catch { + return null; + } + }) + .filter((room: any) => room && room.isActive); + + usersWithExpert = activeRooms.length; + } + } catch (err) { + console.error('Error getting users with expert from Redis:', err); + } + + // 4. تعداد کاربر در انتظار ارتباط با کارشناس - Number of users waiting to connect with expert + // Get from Redis waitingQueue list and waiting_users hash + let waitingUsersCount = 0; + try { + const waitingQueueLength = await redisClient.llen(WAITING_QUEUE_KEY); + const waitingUsersKeys = await redisClient.hkeys(WAITING_USERS_KEY); + waitingUsersCount = waitingQueueLength + waitingUsersKeys.length; + } catch (err) { + console.error('Error getting waiting users from Redis:', err); + } + + // 5. تعداد کاربر چت بات - Number of chatbot users (total unique users with sessions) + const totalChatbotUsers = await this.sessionModel.distinct('userId').then(ids => ids.length); + + // 6. تعداد کاربر بیمه گذار چت بات - Number of insurance holder chatbot users + // Users with nationalCode who have sessions + const usersWithSessions = await this.sessionModel.distinct('userId'); + const insuranceHolderUsers = await this.userModel.countDocuments({ + _id: { $in: usersWithSessions }, + nationalCode: { $exists: true, $nin: [null, ''] }, + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + expertsInConversation: expertsInConversationCount, + onlineExperts: onlineExpertsCount, + usersWithExpert: usersWithExpert, + waitingUsers: waitingUsersCount, + totalChatbotUsers: totalChatbotUsers, + insuranceHolderUsers: insuranceHolderUsers, + }); + } catch (err) { + console.error('Error in getDashboard:', err); + throw new HttpException( + err.message || 'Failed to fetch dashboard data', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/api/admin/dto/create-admin.dto.ts b/src/api/admin/dto/create-admin.dto.ts new file mode 100644 index 0000000..d49bee6 --- /dev/null +++ b/src/api/admin/dto/create-admin.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsStrongPassword } from 'class-validator'; + +export class CreateAdminDto { + @ApiProperty({ description: 'email please' }) + @IsEmail() + email: string; + + @ApiProperty() + @IsStrongPassword({ minLength: 5 }) + password: string; + + @ApiProperty() + mobile: string; +} diff --git a/src/api/admin/dto/create-new-expert.dto.ts b/src/api/admin/dto/create-new-expert.dto.ts new file mode 100644 index 0000000..1af048d --- /dev/null +++ b/src/api/admin/dto/create-new-expert.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsStrongPassword } from 'class-validator'; + +export class createExpertDto { + @ApiProperty({ + required: false, + type: 'string', + description: 'email of the newly added expert (uses this to login)', + example: 'ittalie@expert.com', + }) + @IsEmail() + email: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'a strong password expert uses to login', + example: '123321', + }) +// @IsStrongPassword({ minLength: 5 }) + password: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'mobile of the expert , will be used for notifications and etc', + example: '09912334899', + }) + mobile: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'name of the expert will be showed in profile and any other places in app', + example: 'کارشناس', + }) + name: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'family of the expert will be showed in profile and any other places in app', + example: 'چت بات', + }) + family : string; +} diff --git a/src/api/admin/dto/edit-profile.dto.ts b/src/api/admin/dto/edit-profile.dto.ts new file mode 100644 index 0000000..0e562e9 --- /dev/null +++ b/src/api/admin/dto/edit-profile.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +export class EditProfileDto { + @ApiProperty({ description: 'First Name of user' }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ description: 'Family name of user' }) + @IsOptional() + @IsString() + family?: string; + + @ApiProperty({ description: 'Birth date of user', example: '1373/01/01' }) + @IsOptional() + birthDate?: string; +} diff --git a/src/api/admin/dto/update-admin.dto.ts b/src/api/admin/dto/update-admin.dto.ts new file mode 100644 index 0000000..74613de --- /dev/null +++ b/src/api/admin/dto/update-admin.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateAdminDto } from './create-admin.dto'; + +export class UpdateAdminDto extends PartialType(CreateAdminDto) {} diff --git a/src/api/user/dto/user.dto.ts b/src/api/user/dto/user.dto.ts new file mode 100644 index 0000000..d525c0a --- /dev/null +++ b/src/api/user/dto/user.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsEnum } from 'class-validator'; +import { ReactEnum } from 'src/common/types/react.type'; + +export class UserAskQuestion { + @ApiProperty({ + required: true, + type: 'string', + description: 'user asked question', + example: 'سلام چطوری وام بگیرم ؟', + }) + question: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'current sessionId , if its first question it can be null', + example: '', + }) + sessionId: string; +} + +export class UserReactToMessage { + @ApiProperty({ + required: true, + type: 'string', + description: 'user react to bot answer. like|dislike', + example: 'like', + }) + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const normalized = value.toLowerCase(); + if (normalized === 'like') return ReactEnum.like; + if (normalized === 'dislike') return ReactEnum.dislike; + if (normalized === 'nothing') return ReactEnum.nothing; + return value; + }) + @IsEnum(ReactEnum) + react: ReactEnum; + + @ApiProperty({ + required: true, + type: 'string', + description: 'current messageId , if its first question it can be null', + example: '', + }) + messageId: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'current sessionId , if its first question it can be null', + example: '', + }) + sessionId: string; +} + +export class UserRateTheExpert { + @ApiProperty({ + required: true, + type: 'string', + description: + 'the id of the session of the current chat. online chat session id equals to normal chatting to bot.', + example: '67a704301d45d2cafb4eec57', + }) + sessionId: string; + + @ApiProperty({ + required: true, + type: 'number', + description: 'user rates the expert , a number between 1 to 5', + example: '3', + }) + rate: number; +} diff --git a/src/api/user/user.controller.ts b/src/api/user/user.controller.ts new file mode 100644 index 0000000..7ec7d1c --- /dev/null +++ b/src/api/user/user.controller.ts @@ -0,0 +1,116 @@ +import { Controller, Get, Post, Body, Param, Put } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiDefaultResponse, + ApiOperation, + ApiParam, +} from '@nestjs/swagger'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { + UserAskQuestion, + UserRateTheExpert, + UserReactToMessage, +} from './dto/user.dto'; +import { UserService } from './user.service'; + +@Controller('user') +export class UserController { + constructor(private readonly userService: UserService) {} + + @ApiOperation({ + summary: 'Get User profile', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('profile') + async getProfile(@CurrentIdentity() user: any) { + return this.userService.getProfile(user); + } + + @ApiOperation({ + summary: 'Users ask their question using this api.', + }) + @ApiBody({ type: UserAskQuestion }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Post('ask') + async askQuestion( + @CurrentIdentity() user: any, + @Body() body: UserAskQuestion, + ) { + return this.userService.ask(body, user); + } + + @ApiOperation({ + summary: 'Get user sessions list including chat titles.', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('questions') + async userLastQuestionsHistory(@CurrentIdentity() user: any) { + return this.userService.getUserLastQuestionsHistory(user); + } + + @ApiOperation({ + summary: 'Get single session history details with session id.', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('questions/:sessionId') + @ApiParam({ name: 'sessionId' }) + async userSessionHistory( + @CurrentIdentity() user: any, + @Param('sessionId') sessionId?: string, + ) { + return this.userService.userSessionHistory(user, sessionId); + } + + @ApiOperation({ + summary: 'Get all users frequently asked questions', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('faq') + async usersFaq(@CurrentIdentity() user: any) { + return this.userService.getUsersFaq(user); + } + + @ApiOperation({ + summary: 'Get user insurance policies with installments', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('my-policies') + async getMyPolicies(@CurrentIdentity() user: any) { + return this.userService.getMyPolicies(user); + } + + @ApiOperation({ + summary: 'Users ask their question using this api.', + }) + @ApiBody({ type: UserReactToMessage }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Put('react') + async reactToMessage( + @CurrentIdentity() user: any, + @Body() body: UserReactToMessage, + ) { + return this.userService.react(body, user); + } + + @ApiOperation({ + summary: 'Users ask their question using this api.', + }) + @ApiBody({ type: UserRateTheExpert }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Post('rate') + async rateTheExpert( + @CurrentIdentity() user: any, + @Body() body: UserRateTheExpert, + ) { + return this.userService.rateTheExpert(body, user); + } +} diff --git a/src/api/user/user.module.ts b/src/api/user/user.module.ts new file mode 100644 index 0000000..af10887 --- /dev/null +++ b/src/api/user/user.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AiServiceModule } from 'src/ai-service/ai-service.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { PoliciesModule } from 'src/policies/policies.module'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; + +@Module({ + imports: [DatabaseModule, AiServiceModule, PoliciesModule], + controllers: [UserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/src/api/user/user.service.ts b/src/api/user/user.service.ts new file mode 100644 index 0000000..eb9a323 --- /dev/null +++ b/src/api/user/user.service.ts @@ -0,0 +1,935 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Sender } from 'src/common/types/sender.type'; +import { ReactEnum } from 'src/common/types/react.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { DictionariesModel } from 'src/database/model/dictionaries.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReactsModel } from 'src/database/model/botReact.model'; +import { AiServiceService } from '../../ai-service/ai-service.service'; +import { PoliciesService } from 'src/policies/policies.service'; +import { + UserAskQuestion, + UserRateTheExpert, + UserReactToMessage, +} from './dto/user.dto'; + +@Injectable() +export class UserService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(AdminModel.name) private readonly admin: Model, + @InjectModel(DictionariesModel.name) + private readonly dictionaries: Model, + @InjectModel(ReactsModel.name) + private readonly reacts: Model, + private readonly aiService: AiServiceService, + private readonly policiesService: PoliciesService, + ) {} + + private normalizeReactValue(value: string | ReactEnum): ReactEnum { + if (value === ReactEnum.like || value === ReactEnum.dislike || value === ReactEnum.nothing) { + return value as ReactEnum; + } + if (typeof value === 'string') { + const v = value.toLowerCase(); + if (v === 'like') return ReactEnum.like; + if (v === 'dislike') return ReactEnum.dislike; + if (v === 'nothing') return ReactEnum.nothing; + } + return ReactEnum.nothing; + } + + async getMyPolicies(user) { + try { + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const data = await this.policiesService.getUserPoliciesWithInstallments( + userId, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.log(err); + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Failed to fetch policies', + null, + ); + } + } + + async getProfile(user) { + try { + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const userDoc = await this.user.findOne({ _id: userId }); + + if (!userDoc) + throw new HttpException('user_not_found', HttpStatus.NOT_FOUND); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: userDoc._id, + name: userDoc.name || null, + family: userDoc.family || null, + birthDate: userDoc.birthDate || null, + nationalCode: userDoc.nationalCode || null, + mobile: userDoc.mobile || null, + email: userDoc.email || null, + address: userDoc.address || null, + username: userDoc.username || null, + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async getUserLastQuestionsHistory(user) { + try { + const now = Date.now() / 1000; + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const sessions = await this.session + .find({ userId: userId }) + .select('chatTitle _id createdAt createdISO') + .lean() + .exec(); + + // Calculate date boundaries + const nowMs = Date.now(); + const nowDate = new Date(nowMs); + + // 7 days ago + const sevenDaysAgo = nowMs - (7 * 24 * 60 * 60 * 1000); + + // 6 months ago (using actual month calculation) + const sixMonthsAgoDate = new Date(nowDate); + sixMonthsAgoDate.setMonth(sixMonthsAgoDate.getMonth() - 6); + const sixMonthsAgo = sixMonthsAgoDate.getTime(); + + // 12 months ago (using actual month calculation) + const twelveMonthsAgoDate = new Date(nowDate); + twelveMonthsAgoDate.setMonth(twelveMonthsAgoDate.getMonth() - 12); + const twelveMonthsAgo = twelveMonthsAgoDate.getTime(); + + // Initialize categorized arrays + const recent: any[] = []; // Last 7 days + const lastSixMonths: any[] = []; // Last 6 months (excluding last 7 days) + const previousSixMonths: any[] = []; // 6-12 months ago + + if (sessions && sessions.length > 0) { + for (let session of sessions) { + const sessionData = { + title: session["chatTitle"], + sessionId: session["_id"], + time: session["createdAt"][0], + date: session["createdAt"][1], + }; + + // Use createdISO for accurate date comparison + const sessionDate = session["createdISO"] + ? new Date(session["createdISO"]).getTime() + : null; + + if (sessionDate) { + if (sessionDate >= sevenDaysAgo) { + // Last 7 days (recent) + recent.push(sessionData); + } else if (sessionDate >= sixMonthsAgo) { + // Last 6 months (excluding last 7 days) + lastSixMonths.push(sessionData); + } else if (sessionDate >= twelveMonthsAgo) { + // Previous 6 months (6-12 months ago) + previousSixMonths.push(sessionData); + } + // Sessions older than 12 months are not included + } else { + // Fallback: if createdISO is missing, include in recent (shouldn't happen normally) + recent.push(sessionData); + } + } + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + recent, // Last 7 days + lastSixMonths, // Last 6 months (excluding last 7 days) + previousSixMonths, // 6-12 months ago + now: TimeHelper.unix2PersianTimeAndDate(now), + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async userSessionHistory(user, sessionId) { + try { + console.log('userSessionHistory called with sessionId:', sessionId); + if (!sessionId || !Types.ObjectId.isValid(sessionId)) { + console.error('Invalid sessionId provided to userSessionHistory:', sessionId); + throw new HttpException('invalid_session_id', HttpStatus.BAD_REQUEST); + } + const newSession = await this.session + .findOne({ + _id: new Types.ObjectId(sessionId), + // userId: user._id, + }) + .exec(); + + if (!newSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: newSession._id, + chatTitle: newSession.chatTitle, + connectedToExpert: newSession.connectedToExpert, + onlineChatClosed: newSession.onlineChatClosed, + chatClosed: newSession.chatClosed, + count: `${newSession.messages.filter((message) => message.sender === Sender.Bot).length}/20`, + time: newSession.createdAt[0], + date: newSession.createdAt[1], + messages: newSession.messages, + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async rateTheExpert(body: UserRateTheExpert, user) { + try { + const now = Date.now() / 1000; + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + // Fetch the session from the `sessions` collection + const sessionIdToFind = new Types.ObjectId(body.sessionId); + const newSession = await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, // Ensure the session belongs to the user + }) + .exec(); + + if (!newSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + if (!newSession.connectedToExpert) { + throw new HttpException('expert_not_connected', HttpStatus.BAD_REQUEST); + } + + // Check if the expert field is empty or null + if (!newSession.expert || newSession.expert.trim() === '') { + throw new HttpException('expert_not_assigned', HttpStatus.BAD_REQUEST); + } + + // Update the expert rate in the `sessions` collection + await this.session.updateOne( + { _id: sessionIdToFind }, + { + $set: { + expertRate: body.rate, + }, + }, + ); + + // Update the admin's rate in the `admin` collection + await this.admin.updateOne( + { username: newSession.expert }, + { + $push: { + rate: { + sessionId: body.sessionId, + rate: body.rate, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdAtDate: Date.now(), + }, + }, + }, + ); + + // Return success response + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', null); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async getUsersFaq(user) { + try { + const dictionaries = await this.dictionaries.find({ + isActive: true, + aiCollectionName: { $ne: 'agents' } + }); + + // Combine all questions from all dictionaries into a single array + const allQuestions = dictionaries.flatMap( + (dictionary) => dictionary.questions, + ); + + // Filter out questions where deleted is true + const filteredQuestions = allQuestions.filter( + (question) => question.deleted !== true, + ); + + // Extract only the `question` field from each question object + const questionsOnly = filteredQuestions.map( + (question) => question.question, + ); + + // Function to shuffle an array using Fisher-Yates algorithm + const shuffleArray = (array) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // Shuffle the questions and pick the first 3 + const shuffledQuestions = shuffleArray(questionsOnly); + const randomQuestions = shuffledQuestions.slice(0, 3); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', randomQuestions); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async react(body: UserReactToMessage, userData) { + try { + const { sessionId, messageId } = body; + const normalizedReact = this.normalizeReactValue((body as any).react); + const sessionObjectId = new Types.ObjectId(sessionId); + const userObjectId = new Types.ObjectId(userData._id); + const storedMessageId = this.toStoredMessageId(messageId); + + // Update the message's react field in the session + // Use updateOne first to ensure the update happens, then fetch the result + const updateResult = await this.session.updateOne( + { + _id: sessionObjectId, + 'messages.messageId': storedMessageId, + }, + { + $set: { + 'messages.$.react': normalizedReact, + }, + }, + ); + + if (updateResult.matchedCount === 0) { + throw new HttpException('message_not_found', HttpStatus.NOT_FOUND); + } + + // Fetch the updated session to get the reacted message + const result = await this.session.findById(sessionObjectId); + if (!result) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + const reactedMessage = result.messages.find( + (message) => String(message.messageId) === String(messageId), + ); + + // Verify the update actually happened + if (reactedMessage && reactedMessage.react !== normalizedReact) { + console.warn(`[react] Warning: React update may have failed. Expected: ${normalizedReact}, Got: ${reactedMessage.react}`); + } + + if (reactedMessage) { + const botMessageIndex = result.messages.findIndex( + (message) => String(message.messageId) === String(messageId), + ); + + let questionText = null; + if (botMessageIndex > 0) { + for (let i = botMessageIndex - 1; i >= 0; i--) { + if (result.messages[i].sender === Sender.User) { + questionText = result.messages[i].text; + break; + } + } + } + + // Update or create the react entry in the reacts collection + // This ensures we only have one react per user per message (the latest one) + await this.reacts.findOneAndUpdate( + { + sessionId: sessionObjectId, + messageId: storedMessageId, + userId: userObjectId, + }, + { + $set: { + question: questionText, + answer: reactedMessage.text, + sender: reactedMessage.sender, + react: normalizedReact, + createdAt: TimeHelper.unix2PersianTimeAndDate(Date.now() / 1000), + createdISO: new Date(), + }, + }, + { + upsert: true, // Create if doesn't exist, update if it does + new: true, + }, + ); + } + + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: result._id, + chatTitle: result.chatTitle, + count: `${result.messages.filter((message) => message.sender === Sender.Bot).length}/20`, + time: result.createdAt[0], + date: result.createdAt[1], + messages: result.messages, + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private toStoredMessageId(messageId: string): Types.ObjectId | string { + if ( + Types.ObjectId.isValid(messageId) && + String(new Types.ObjectId(messageId)) === String(messageId) + ) { + return new Types.ObjectId(messageId); + } + return messageId; + } + + public async insertNewChatNewStructure(sessionId, text, sender, now) { + const newMessage = { + messageId: new Types.ObjectId(), + text: text, + sender: sender, + react: sender === Sender.User ? 'Nothing' : null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }; + + await this.session.updateOne( + { _id: sessionId }, + { + $push: { + messages: newMessage, + }, + }, + ); + } + + public async editChatMessage( + sessionId: string, + messageId: string, + newMessageText: string, + ): Promise { + const sessionObjectId = new Types.ObjectId(sessionId); + const storedMessageId = this.toStoredMessageId(messageId); + + const result = await this.session.findOneAndUpdate( + { + _id: sessionObjectId, + 'messages.messageId': storedMessageId, + }, + { + $set: { + 'messages.$.text': newMessageText, + 'messages.$.edited': true, + }, + }, + { new: true }, + ); + + if (!result) { + return null; + } + + // Find the updated message within the session's messages array + const updatedMessage = result.messages.find( + (msg) => msg.messageId.toString() === messageId, + ); + return { + ...updatedMessage, + sender: updatedMessage.sender, // Ensure sender role is explicitly returned + }; } + + async ask(body: UserAskQuestion, user) { + try { + if (!body.question) { + throw new HttpException('question_required', HttpStatus.BAD_REQUEST); + } + + const now = Date.now() / 1000; + + if (!body.sessionId) { + return await this.handleNewSession(body, user, now); + } else { + return await this.handleExistingSession(body, user, now); + } + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async handleNewSession(body, user, now) { + try { + console.log('new session'); + let aiResponse; + let isAiServiceAvailable = true; + + try { + const timeoutMs = parseInt(process.env.AI_SERVICE_TIMEOUT || '65000', 10); // Default 60 seconds + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new HttpException('ai_service_timeout', HttpStatus.GATEWAY_TIMEOUT), + ); + }, timeoutMs); + }); + + const aiRequest = await this.buildAiRequestPayload(body, user, []); + + aiResponse = await Promise.race([ + this.aiService.ask(aiRequest, null), + timeoutPromise, + ]); + + if (aiResponse.statusCode == 500) { + isAiServiceAvailable = false; + } + } catch (aiError) { + // Catch any AI service errors (timeout, network errors, 500 errors, etc.) + console.log('AI service error caught:', aiError); + isAiServiceAvailable = false; + } + + let messages; + if (!isAiServiceAvailable) { + // AI service is unavailable, insert fallback message + messages = [ + { + messageId: new Types.ObjectId(), + text: body.question, + sender: Sender.User, + react: 'Nothing', + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + { + messageId: new Types.ObjectId(), + text: "به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. ", + sender: Sender.Bot, + react: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + ]; + } else { + // AI service responded successfully + messages = [ + { + messageId: new Types.ObjectId(), + text: body.question, + sender: Sender.User, + react: 'Nothing', + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + { + messageId: new Types.ObjectId(), + text: aiResponse, + sender: Sender.Bot, + react: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + ]; + } + + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + + const newSession = { + _id: new Types.ObjectId(), + userId: userId, + chatTitle: body.question, // TODO: AI wrap-up needed + connectedToExpert: false, + onlineChatClosed: false, + chatClosed: false, + expert: '', + expertRate: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + messages: messages, + }; + + await this.session.create(newSession); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: newSession._id, + count: `1/20`, + date: TimeHelper.unix2PersianTimeAndDate(now)[1], + time: TimeHelper.unix2PersianTimeAndDate(now)[0], + question: body.question, + answer: messages[1].text, + history: newSession.messages, + }); + + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async handleExistingSession(body, user, now) { + try { + const sessionIdToFind = new Types.ObjectId(body.sessionId); + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const newSession = await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, + }) + .exec(); + + if (!newSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + if (newSession.chatClosed) { + throw new HttpException( + 'session_limit_reached', + HttpStatus.BAD_REQUEST, + ); + } + + let aiResponse; + let isAiServiceAvailable = true; + const fallbackMessage = "به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. "; + + try { + // Take last 6 messages (3 complete user-assistant pairs) instead of 7 + // to ensure we have complete pairs for chat_history_raw + const historyToSend = newSession.messages.slice(-6); // Get the last 6 messages (3 pairs) + + const transformedData = await this.buildAiRequestPayload( + body, + user, + this.transformChatHistory(historyToSend), + ); + + aiResponse = await this.aiService.ask( + transformedData, + body.sessionId, + ); + + if (aiResponse.statusCode == 500) { + isAiServiceAvailable = false; + } + } catch (aiError) { + // Catch any AI service errors (network errors, 500 errors, etc.) + console.log('AI service error caught in existing session:', aiError); + isAiServiceAvailable = false; + } + + // Insert user question + await this.insertNewChatNewStructure( + sessionIdToFind, + body.question, + Sender.User, + now, + ); + + // Insert AI response or fallback message + const responseText = isAiServiceAvailable ? aiResponse : fallbackMessage; + await this.insertNewChatNewStructure( + sessionIdToFind, + responseText, + Sender.Bot, + now, + ); + + const updatedSession = await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, + }) + .exec(); + + const botMessageCount = updatedSession.messages.filter( + (message) => message.sender === Sender.Bot, + ).length; + + if (botMessageCount >= 20) { + await this.session.updateOne( + { _id: sessionIdToFind }, + { $set: { chatClosed: true } }, + ); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: updatedSession._id, + count: `${botMessageCount}/20`, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + question: body.question, + answer: responseText, + history: updatedSession.messages, + }); + + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + transformChatHistory(messages) { + const history = []; + for (let i = 0; i < messages.length; i += 2) { + const userMessage = messages[i]; + const botMessage = messages[i + 1]; + + if (userMessage && botMessage) { + history.push({ + user: userMessage.text, + assistant: botMessage.text, + // userCreatedAt: userMessage.createdAt, + // botCreatedAt: botMessage.createdAt, + }); + } + } + return history; + } + + private async buildAiRequestPayload( + body: UserAskQuestion, + user: { _id: Types.ObjectId | string }, + chatHistory: { user: string; assistant: string }[], + ) { + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const userDoc = await this.user + .findById(userId) + .select('nationalCode') + .lean() + .exec(); + + const { user_insurance_data, user_installments_data } = + await this.policiesService.getUserDataForAi( + userId, + userDoc?.nationalCode, + ); + + return { + user_input: body.question, + chat_history_raw: chatHistory, + user_insurance_data, + user_installments_data, + }; + } + + async closeChatSession(userId, sessionId) { + // ? migrated + try { + // Close the chat session in the old structure ** old-struc ** + await this.user.updateOne( + { _id: userId }, + { + $set: { + 'chat.$[chatElement].chatClosed': true, + }, + }, + { + arrayFilters: [ + { 'chatElement.sessionId': new Types.ObjectId(sessionId) }, + ], + }, + ); + + // ** old-struc ** + + // Close the chat session in the new structure ** new-struc ** + const sessionIdToFind = new Types.ObjectId(sessionId); + await this.session.updateOne( + { _id: sessionIdToFind, userId: userId }, + { $set: { chatClosed: true } }, + ); + + // Log the new structure data for verification ** new-struc ** + await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, + }) + .exec(); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findOneUser(filter: FilterQuery): Promise { + return await this.user.findOne(filter); + } + + async updateOneUser( + filter: FilterQuery, + update: UpdateQuery, + ): Promise { + return await this.user.updateOne(filter, update).lean(); + } + + // public async toggleExpertActions( + // userId: string, + // sessionId: string, + // field: string, + // ): Promise { + // try { + // // Validate the field + // if (!['connectedToExpert', 'onlineChatClosed'].includes(field)) { + // throw new Error('Invalid field specified'); + // } + + // // Toggle the field in the new structure + // const sessionIdToFind = new Types.ObjectId(sessionId); + // const result = await this.session.updateOne( + // { _id: sessionIdToFind, userId: userId }, + // { $set: { [field]: true } }, + // ); + + // // Check if the update was successful + // if (result.matchedCount === 0) { + // throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + // } + + // // Log the new structure data for verification + // const updatedSession = await this.session + // .findOne({ + // _id: sessionIdToFind, + // userId: userId, + // }) + // .exec(); + // } catch (err) { + // console.log(err); + // throw new BaseResponseDTO(err.status, err.response, null); + // } + // } + public async toggleExpertActions( + userId: string, + sessionId: string, + field: string, + expertId?: string, + ): Promise { + try { + console.log('toggleExpertActions called with:', { userId, sessionId, field, expertId }); + if (!['connectedToExpert', 'onlineChatClosed'].includes(field)) { + throw new Error('Invalid field specified'); + } + + // Additional validation for expertId when field is connectedToExpert + if (field === 'connectedToExpert' && !expertId) { + throw new Error('expertId is required when field is connectedToExpert'); + } + + const sessionIdToFind = new Types.ObjectId(sessionId); + const userID = + typeof userId === 'string' ? new Types.ObjectId(userId) : userId; + + const session = await this.session.findOne({ _id: sessionIdToFind, userId: userID }).exec(); + + if (!session) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + // New validation logic + if (field === 'connectedToExpert' && session.connectedToExpert && session.chatClosed) { + console.error('User already connected to expert in a closed session for sessionId:', sessionId); + throw new HttpException('you_have_connected_to_expert_once', HttpStatus.BAD_REQUEST); + } + + let updateData: any = { [field]: true }; + + // If connecting to expert, get expert username and add to update + if (field === 'connectedToExpert' && expertId) { + console.log('Attempting to find expert with expertId:', expertId); + const expert = await this.admin + .findOne({ _id: new Types.ObjectId(expertId) }, { username: 1 }) + .exec(); + + if (!expert) { + console.error('Expert not found for expertId:', expertId); + throw new HttpException('expert_not_found', HttpStatus.NOT_FOUND); + } + + updateData.expert = expert.username; + } + + // Update the session + console.log('Updating session with sessionIdToFind:', sessionIdToFind, 'and userID:', userID); + const result = await this.session.updateOne( + { _id: sessionIdToFind, userId: userID }, + { $set: updateData }, + ); + + if (result.matchedCount === 0) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + await this.session + .findOne({ + _id: sessionIdToFind, + userId: userID, + }) + .exec(); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..7e08f69 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,76 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import * as dotenv from 'dotenv'; +import { AppGuard } from './auth/auth.guard'; +import { IpRateLimiterGuard } from './auth/guards/ip-rate-limiter.guard'; +import { ApiKeyAuthGuard } from './auth/guards/api-key-auth.guard'; +import { RedisService } from './common/helpers/redis.service'; +import { getAppImports } from './common/helpers/versioning.helper'; +import { SsoService } from './externals/sso/sso.service'; +import { ReportsModule } from './reports/reports.module'; +import { UserManagementModule } from './user-management/user-management.module'; +import { WidgetModule } from './widget/widget.module'; +import { ClientManagementModule } from './client-management/client-management.module'; +import { BusinessHoursModule } from './business-hours/business-hours.module'; +import { ExpertPreparedMessagesModule } from './expert-prepared-messages/expert-prepared-messages.module'; +import { PoliciesModule } from './policies/policies.module'; + +dotenv.config(); +dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); +console.log(process.env.REDIS_URL); +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: `.local.env`, + isGlobal: true, + }), + ScheduleModule.forRoot(), + ThrottlerModule.forRoot({ + throttlers: [ + { + name: 'auth', + ttl: 60000, + limit: 5, + }, + { + name: 'global', + ttl: 60000, + limit: 100, + }, + ], + ignoreUserAgents: [/.*/], // Optional: ignore certain user agents + skipIf: () => false, // Ensure it runs for all requests + generateKey: (context) => { + // Custom key generator without exposing details + const req = context.switchToHttp().getRequest(); + return req.ip; + }, + }), + ...getAppImports(), + UserManagementModule, + ReportsModule, + WidgetModule, + ClientManagementModule, // Ensure ClientManagementModule is imported + BusinessHoursModule, + ExpertPreparedMessagesModule, + PoliciesModule, + ], + controllers: [], + providers: [ + RedisService, + SsoService, + { + provide: APP_GUARD, + useClass: AppGuard, + }, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + IpRateLimiterGuard, + ], +}) +export class AppModule {} diff --git a/src/audio/audio-normalization.errors.ts b/src/audio/audio-normalization.errors.ts new file mode 100644 index 0000000..3199acd --- /dev/null +++ b/src/audio/audio-normalization.errors.ts @@ -0,0 +1,6 @@ +export class AudioNormalizationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AudioNormalizationError'; + } +} diff --git a/src/audio/audio-normalization.service.ts b/src/audio/audio-normalization.service.ts new file mode 100644 index 0000000..7d166c9 --- /dev/null +++ b/src/audio/audio-normalization.service.ts @@ -0,0 +1,216 @@ +import { spawn } from 'node:child_process'; +import { stat, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AudioNormalizationError } from './audio-normalization.errors'; + +/** + * Normalizes arbitrary browser voice captures to **AAC-LC in MP4** (`.m4a`, `audio/mp4`). + * + * Rationale: AAC-LC in an MP4 container is natively supported by iOS Safari, Android Chrome, + * and desktop browsers; `-movflags +faststart` moves metadata to the head of the file for + * faster start/seek over HTTP. Mono + 96k is a strong voice default (size vs quality). + */ +@Injectable() +export class AudioNormalizationService { + private readonly logger = new Logger(AudioNormalizationService.name); + private readonly ffmpegPath: string; + private readonly ffprobePath: string; + private readonly transcodeTimeoutMs: number; + + constructor(private readonly config: ConfigService) { + this.ffmpegPath = this.config.get('AUDIO_FFMPEG_PATH')?.trim() || 'ffmpeg'; + this.ffprobePath = this.config.get('AUDIO_FFPROBE_PATH')?.trim() || 'ffprobe'; + const raw = this.config.get('AUDIO_TRANSCODE_TIMEOUT_MS'); + const n = raw != null ? Number(raw) : NaN; + this.transcodeTimeoutMs = Number.isFinite(n) && n > 0 ? n : 120_000; + } + + /** + * Transcode any input FFmpeg can demux to single-channel AAC in MP4 (`.m4a`). + * Caller must delete `outputAbsolutePath` and the input temp file after upload. + */ + async transcodeIncomingVoiceToM4a( + inputAbsolutePath: string, + ): Promise<{ outputAbsolutePath: string; durationSec?: number }> { + const outputAbsolutePath = join(tmpdir(), `voice-norm-${randomUUID()}.m4a`); + + const args = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-i', + inputAbsolutePath, + '-vn', + '-sn', + '-dn', + '-ac', + '1', + '-ar', + '48000', + '-c:a', + 'aac', + '-b:a', + '96k', + '-profile:a', + 'aac_low', + '-movflags', + '+faststart', + outputAbsolutePath, + ]; + + try { + await this.runProcess(this.ffmpegPath, args, 'ffmpeg'); + } catch (e: unknown) { + await unlink(outputAbsolutePath).catch(() => {}); + throw e; + } + + try { + const st = await stat(outputAbsolutePath); + if (!st.size) { + throw new AudioNormalizationError('normalized_audio_empty'); + } + } catch (e: unknown) { + await unlink(outputAbsolutePath).catch(() => {}); + throw e; + } + + let durationSec: number | undefined; + try { + durationSec = await this.probeDurationSec(outputAbsolutePath); + } catch (err: unknown) { + this.logger.debug(`ffprobe duration skipped: ${(err as Error)?.message}`); + } + + return { outputAbsolutePath, durationSec }; + } + + private async probeDurationSec(filePath: string): Promise { + const args = [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + filePath, + ]; + const stdout = await this.runProcessCaptureStdout(this.ffprobePath, args, 'ffprobe'); + const n = parseFloat(String(stdout).trim()); + return Number.isFinite(n) ? n : undefined; + } + + private runProcess( + command: string, + args: string[], + label: string, + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + let stderr = ''; + const onData = (c: Buffer) => { + stderr += c.toString(); + if (stderr.length > 64_000) stderr = stderr.slice(-32_000); + }; + child.stderr?.on('data', onData); + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new AudioNormalizationError(`${label}_timeout_${this.transcodeTimeoutMs}ms`), + ); + }, this.transcodeTimeoutMs); + + child.on('error', (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + if (err?.code === 'ENOENT') { + reject( + new AudioNormalizationError( + `${label}_binary_not_found_install_ffmpeg_package`, + ), + ); + } else { + reject(err); + } + }); + + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(); + return; + } + const tail = stderr.trim().slice(-800); + reject( + new AudioNormalizationError( + `${label}_failed_exit_${code}${tail ? `:${tail}` : ''}`, + ), + ); + }); + }); + } + + private runProcessCaptureStdout( + command: string, + args: string[], + label: string, + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr?.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new AudioNormalizationError(`${label}_timeout_${this.transcodeTimeoutMs}ms`), + ); + }, this.transcodeTimeoutMs); + + child.on('error', (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + if (err?.code === 'ENOENT') { + reject( + new AudioNormalizationError( + `${label}_binary_not_found_install_ffmpeg_package`, + ), + ); + } else { + reject(err); + } + }); + + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(stdout); + return; + } + reject( + new AudioNormalizationError( + `${label}_failed_exit_${code}:${stderr.trim().slice(-400)}`, + ), + ); + }); + }); + } +} diff --git a/src/audio/audio.module.ts b/src/audio/audio.module.ts new file mode 100644 index 0000000..276f1cd --- /dev/null +++ b/src/audio/audio.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AudioNormalizationService } from './audio-normalization.service'; + +@Module({ + providers: [AudioNormalizationService], + exports: [AudioNormalizationService], +}) +export class AudioModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..cd39f21 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,147 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Post, + Req, + Request, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { ForgetPasswordDTO } from 'src/common/dto/forgetPassword.dto'; +import { + AdminLoginDTO, + UserLoginDTO, + UserVerifyOtpDTO, +} from 'src/common/dto/login.dto'; +import { ResetPasswordDTO } from 'src/common/dto/resetPassword.dto'; +import { AdminGuard } from './guards/admin.guard'; +import { IpRateLimiterGuard } from './guards/ip-rate-limiter.guard'; +import { Public } from './auth.decorator'; +import { AuthService } from './auth.service'; +import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; + +interface AuthenticatedRequest extends Request { + client?: { + apiKey: string; + name: string; + enName: string; + status: string; + }; +} + +@Controller('/auth') +@ApiTags('Authorization') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @Throttle({ + default: { + limit: process.env.NODE_ENV === 'production' ? 3 : 100, + ttl: 60000, + }, + }) + @UseGuards(IpRateLimiterGuard, ApiKeyAuthGuard) + @Post('/user-login') + async login(@Body() body: UserLoginDTO, @Request() req: AuthenticatedRequest) { + const origin = req.headers['origin'] as string; + + console.log(req.client) + if (req.client && req.client.enName === 'Saman-Insurance') { + // Call loginV4 or a specific function for API Key 1 + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginV4(body); + } else if (req.client && req.client.enName === 'Parsian-Insurance') { + // Call loginV3 or a specific function for API Key 2 + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginParsian(body); + } else if(req.client && req.client.enName === 'Saramad-Insurance'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginSaramad(body); + } else if(req.client && req.client.enName === 'Tamasino'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginV4(body); + } else if(req.client && req.client.enName === 'Asia-Insurance'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginAsia(body); + } + } + + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @Throttle({ default: { limit: 5, ttl: 120000 } }) // 5 requests per 2 minutes + @UseGuards(IpRateLimiterGuard,ApiKeyAuthGuard) + @Post('/user-login-verify') + async userVerify(@Body() body: UserVerifyOtpDTO, @Request() req) { + const origin = req.headers['origin'] as string; + console.log(req.client) + console.log('req.client') + if(body.mobile === "09226187419"){ + return this.authService.loginVerifyTest(body); + }else if (req.client && req.client.enName === 'Saman-Insurance' || req.client.enName==='Saramad-Insurance') { + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + } else if (req.client && req.client.enName === 'Parsian-Insurance') { + // Call loginV3 or a specific function for API Key 2 + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + }else if(req.client && req.client.enName === 'Tamasino'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + }else if(req.client && req.client.enName === 'Asia-Insurance'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + } + } + + // @ApiBearerAuth() + @Public() + // @Throttle({ + // default: { + // limit: process.env.NODE_ENV === 'production' ? 3 : 100, + // ttl: 60000, + // }, + // })/ + @Throttle({ default: { limit: 5, ttl: 120000 } }) + @UseGuards(AdminGuard) + @Post('/admin-login') + async adminLogin(@Body() body: AdminLoginDTO, @Request() req) { + return new BaseResponseDTO(HttpStatus.ACCEPTED, 'SUCCESS', { + accessToken: req.token, + role: req.admin.role, + _id: req.admin._id, + username: req.admin.username, + }); + } + + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @UseGuards(IpRateLimiterGuard, ApiKeyAuthGuard) + @Post('/admin-forget-password') + async adminRecoveryPassword(@Body() email: ForgetPasswordDTO) { + return this.authService.forgetPassword(email); + } + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @UseGuards(IpRateLimiterGuard,ApiKeyAuthGuard) + @Post('/admin-reset-password') + async adminResetPassword( + @AdminIdentity() adminIdentity, + @Body() Body: ResetPasswordDTO, + ) { + return this.authService.resetPassword(Body, adminIdentity); + } + + @Get('logout') + @ApiBearerAuth() + async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + return this.authService.logout(req, res); + } +} diff --git a/src/auth/auth.decorator.ts b/src/auth/auth.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/auth/auth.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts new file mode 100644 index 0000000..bee5af3 --- /dev/null +++ b/src/auth/auth.guard.ts @@ -0,0 +1,62 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { EncryptionHelper } from 'src/common/tools/encryption-helper'; +import { IS_PUBLIC_KEY } from './auth.decorator'; + +@Injectable() +export class AppGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly reflector: Reflector, + private readonly redisService: RedisService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException('توکن الزامی است'); + } + + try { + const isBlacklisted = await this.redisService.isTokenBlacklisted(token); // ✅ Using service method + if (isBlacklisted) { + throw new UnauthorizedException('توکن در لیست سیاه قرار دارد'); + } + + const decryptedToken = EncryptionHelper.decrypt(token); + const payload = await this.jwtService.verifyAsync(decryptedToken, { + secret: process.env.auth_jwt_secret, + // issuer: process.env.auth_jwt_issuer, + }); + // await bcrypt. JSON.parse(payload) + request['user'] = payload; + } catch { + throw new UnauthorizedException('عدم احراز هویت'); + } + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..23d0b79 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { DatabaseModule } from 'src/database/database.module'; +import { SmsModule } from 'src/externals/sms/sms.module'; +import { SsoModule } from 'src/externals/sso/sso.module'; +import { ClientManagementModule } from 'src/client-management/client-management.module'; +import { PoliciesModule } from 'src/policies/policies.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { LocalStrategy } from './local.strategy'; + +@Module({ + imports: [ + PassportModule, + DatabaseModule, + SsoModule, + SmsModule, + ClientManagementModule, + PoliciesModule, + JwtModule.register({ + secret: '@#@!#$@#!TOKEN$#$@!#()(^&', + global: true, + signOptions: { expiresIn: '1d' }, + }), + ], + providers: [AuthService, LocalStrategy, JwtService, RedisService], + exports: [AuthService, RedisService], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..d37eafe --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,1287 @@ +import * as crypto from 'node:crypto'; +import * as https from 'node:https'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { JwtService } from '@nestjs/jwt'; +import axios from 'axios'; +import { Model } from 'mongoose'; +import { AdminService } from 'src/api/admin/admin.service'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { + UserLoginDTO, + AdminLoginDTO, + UserVerifyOtpDTO, +} from 'src/common/dto/login.dto'; +import { ForgetPasswordDTO } from 'src/common/dto/forgetPassword.dto'; +import { ResetPasswordDTO } from 'src/common/dto/resetPassword.dto'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { EncryptionHelper } from 'src/common/tools/encryption-helper'; +import { Role } from 'src/common/types/role.type'; +import { UserModel } from 'src/database/model/user.model'; +import { SamanSmsService } from 'src/externals/sms/saman.service'; +import { SsoService } from 'src/externals/sso/sso.service'; +import { PoliciesService } from 'src/policies/policies.service'; +import { Identity } from './models/identity.model'; + +@Injectable() +export class AuthService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + private readonly jwtService: JwtService, + private readonly adminService: AdminService, + private readonly smsService: SamanSmsService, + private readonly ssoService: SsoService, + private readonly redisService: RedisService, + private readonly policiesService: PoliciesService, + private readonly auditLogService: AuditLogService, + ) {} + async login(userData: UserLoginDTO) { + try { + // if (process.env.SSO_ENABLED == 'true') { + // } else { + if (!userData.mobile) + throw new HttpException('شماره موبایل الزامی است', HttpStatus.BAD_REQUEST); + if (userData.twoFactor && !userData.nationalCode) + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + + let user = await this.user.findOne({ mobile: userData.mobile }); + const otp = Math.floor(10000 + Math.random() * 90000).toString(); + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp, + role: 'user', + }); + // TODO SMS SENDING FUNCTION + await this.smsService.smsSender(otp, userData.mobile); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + otp: otp, + }); + } else { + // TODO SMS SENDING FUNCTION + await this.smsService.smsSender(otp, userData.mobile); + + await this.user.updateOne({ mobile: userData.mobile }, { otp: otp }); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } + // } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async loginV3(userData: UserLoginDTO) { + try { + const now = new Date(); + if (!userData.mobile) throw new HttpException('شماره موبایل الزامی است', HttpStatus.BAD_REQUEST); + if (userData.twoFactor && !userData.nationalCode) throw new HttpException('کد ملی الزامی است', HttpStatus.BAD_REQUEST); + if (process.env.SSO_ENABLED == 'true' && userData.twoFactor) { + if (!userData.nationalCode) { + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + } + + // Call SSO Signin API + const ssoResponse = await axios.post( + 'https://stg.cp-api.si24.ir/Signin', + { + nationalCode: userData.nationalCode, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), // ⚠️ Bypasses SSL verification + timeout: 15000, + }, + ); + + if ( + ssoResponse.data.code !== 0 || + !ssoResponse.data.data?.phoneNumber + ) { + const errorMessage = + ssoResponse.data.errorMessage || 'ورود از طریق سامانه احراز هویت با خطا مواجه شد'; + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + + let user = await this.user.findOne({ mobile: userData.mobile }); + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode, + otp: null, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: true, + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: null, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent_via_sso', { + _id: user._id, + mobile: user.mobile, + ssoEnabled: true, + }); + } else if ( + process.env.SSO_ENABLED == 'true' && + !userData.twoFactor + ) { + console.log('notify'); + await this.handleNormalLogin(userData); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent_via_notify', { + mobile: userData.mobile, + ssoEnabled: true, + }); + } else { + let user = await this.user.findOne({ mobile: userData.mobile }); + const otp = Math.floor(10000 + Math.random() * 90000).toString(); + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp, + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + await this.smsService.smsSender(otp, userData.mobile); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } else { + await this.smsService.smsSender(otp, userData.mobile); + await this.user.updateOne( + { mobile: userData.mobile }, + { otp: otp, otpCreatedAt: now, otpAttempts: 0 }, + ); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } + } + } catch (err) { + console.error('Login Error:', err); + + // Handle HttpException (your custom errors) + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + // Handle Axios errors (from SSO API or Kavenegar API) + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + // Handle request timeout + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + // Handle generic errors + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginParsian(userData:UserLoginDTO){ + try { + const now = new Date(); + + const user = await this.user.findOne({mobile:userData.mobile}); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن بیمه پارسیان +Code: ${otp}`; + let result; + if(!user){ + await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode, + otp, + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: false, + }); + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'X-PACKAGE-API-KEY': process.env.PARSIAN_API_KEY, + 'Authorization': `Basic ${process.env.PARSIAN_BASIC_TOKEN}` + }, + }; + const singleSendUri = `${process.env.PARSIAN_SMS_URL}=${userData.mobile}&Message=${message}`; + const response = await axios.get(singleSendUri, axiosConfig); + console.log(response.data) + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + }else { + user.otp = otp; + user.otpCreatedAt= now, + + user.save(); + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'X-PACKAGE-API-KEY': process.env.PARSIAN_API_KEY, + 'Authorization': `Basic ${process.env.PARSIAN_BASIC_TOKEN}` + }, + }; + const singleSendUri = `${process.env.PARSIAN_SMS_URL}=${userData.mobile}&Message=${message}`; + const response = await axios.get(singleSendUri, axiosConfig); + console.log(response.data) + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + //deprecated + async loginV2(userData: UserLoginDTO) { + try { + this.validateUserData(userData); + + if (process.env.SSO_ENABLED === 'true') { + return await this.handleNormalLogin(userData); + } else { + return await this.handleDevelopmentLogin(userData); + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private validateUserData(userData: UserLoginDTO) { + if (!userData.mobile) { + throw new HttpException('شماره موبایل الزامی است', HttpStatus.BAD_REQUEST); + } + if (userData.twoFactor && !userData.nationalCode) { + throw new HttpException('کد ملی الزامی است', HttpStatus.BAD_REQUEST); + } + } + + private async handleNormalLogin(userData: UserLoginDTO) { + const now = new Date(); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن بیمه سامان +Code: ${otp}`; + let user = await this.user.findOne({ mobile: userData.mobile }); + if (!user) { + await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode, + otp: null, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: true, + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: null, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + const notify = await this.ssoService.notifyService( + userData.mobile, + message, + 'otp', + ); + console.log(notify); + console.log('notify in loginV3'); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + + private async handleDevelopmentLogin(userData: UserLoginDTO) { + let user = await this.user.findOne({ mobile: userData.mobile }); + const otp = this.generateOtp(); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode || null, + otp, + role: 'user', + }); + } else { + await this.user.updateOne({ mobile: userData.mobile }, { otp }); + } + + await this.sendSms(otp, userData.mobile); + + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } + + private generateOtp(): string { + return Math.floor(10000 + Math.random() * 90000).toString(); + } + + private async sendSms(otp: string, mobile: string) { + await this.smsService.smsSender(otp, mobile); + } + + private async clearOtp(mobile: string): Promise { + await this.user.updateOne( + { mobile }, + { + otp: null, + otpCreatedAt: null, + otpAttempts: 0, + }, + ); + } + + async userVerify(userData: UserVerifyOtpDTO) { + try { + if (!userData.mobile || !userData.otp) + throw new HttpException('اطلاعات ورودی ناقص است', HttpStatus.BAD_REQUEST); + const user = await this.user + .findOne({ mobile: userData.mobile }) + .select('+otp +otpCreatedAt +otpAttempts +nationalCode'); + if (!user) + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + + const ssoEnabled = process.env.SSO_ENABLED; + + if (ssoEnabled && user.otp == null) { + // SSO verification flow + if (!user.nationalCode) { + throw new HttpException( + 'کد ملی یافت نشد', + HttpStatus.BAD_REQUEST, + ); + } + + try { + // Call SSO OTP verification API + const ssoVerifyResponse = await axios.post( + process.env.SAMAN_SSO_VERIFY_URL, + { + nationalCode: user.nationalCode, + otp: userData.otp, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ + rejectUnauthorized: false, // ⚠️ Only for testing! + }), + timeout: 15000, + }, + ); + + if (ssoVerifyResponse.data.code !== 0) { + const errorCode = ssoVerifyResponse.data.code; + let errorMessage = + ssoVerifyResponse.data.errorMessage || 'خطا در تایید احراز هویت'; + switch (errorCode) { + case 602: + errorMessage = 'کد وارد شده صحیح نمی باشد.'; + break; + case 614: + errorMessage = 'لطفا 2 دقیقه دیگر مجدد تلاش نمایید'; + break; + case 600: + errorMessage = 'رمز یکبارمصرف منقضی شده است.'; + break; + case 615: + errorMessage = 'تعداد درخواستهای شما بیشتر از حد مجاز است.'; + break; + case 612: + errorMessage = 'کد ملی وارد شده معتبر نمی باشد'; + break; + default: + errorMessage = + ssoVerifyResponse.data.errorMessage || 'خطا در احراز هویت'; + console.log( + `Unhandled SSO error code: ${errorCode}`, + ssoVerifyResponse.data, + ); + break; + } + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + + const samanToken = ssoVerifyResponse.data.data.access_token; + const decodedToken = this.jwtService.decode(samanToken); + + const updatePayload: any = { + ssoEnabled: true, + lastLogin: new Date(), + }; + + if (!user.name && decodedToken.given_name) { + updatePayload.name = decodedToken.given_name; + } + if (!user.family && decodedToken.family_name) { + updatePayload.family = decodedToken.family_name; + } + if (!user.nationalCode && decodedToken.nationalCode) { + updatePayload.nationalCode = decodedToken.nationalCode; + } + + // Update user document if needed + if (Object.keys(updatePayload).length > 2) { + // More than just ssoEnabled and lastLogin + await this.user.updateOne( + { _id: user._id }, + { $set: updatePayload }, + ); + } + + await this.clearOtp(user.mobile); + const accessToken = await this.generateJwtToken(user, Role.User); + + // Fetch and store policies if SSO enabled, twoFactor is true, and both mobile and nationalCode exist + console.log('=== Policy Fetch Check ==='); + console.log('SSO_ENABLED:', process.env.SSO_ENABLED); + console.log('user.mobile:', user.mobile); + console.log('user.nationalCode:', user.nationalCode); + + if ( + process.env.SSO_ENABLED === 'true' && + user.mobile && + user.nationalCode + ) { + console.log('✅ Starting policy sync for user:', user._id); + try { + const result = + await this.policiesService.fetchAndStorePoliciesWithInstallments( + user._id, + user.nationalCode, + ); + console.log( + `✅ Successfully synced ${result.policiesCount} policies and ${result.installmentsCount} installments for user ${user._id}`, + ); + } catch (error) { + console.error( + `❌ Failed to sync policies for user ${user._id}:`, + error, + ); + } + } else { + console.log('❌ Policy fetch skipped. Conditions not met.'); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', accessToken); + } catch (error) { + console.error('SSO Verification Error:', { + message: error.message, + code: error.code, + response: error.response?.data, + status: error.response?.status, + statusText: error.response?.statusText, + }); + + if (error instanceof HttpException) { + throw error; + } + + // Check if we have a valid error response from SSO service + if ( + error.response?.data?.code && + error.response?.data?.errorMessage + ) { + const errorCode = error.response.data.code; + let errorMessage = error.response.data.errorMessage; + + // Map the error code to a specific message if needed + switch (errorCode) { + case 602: + errorMessage = 'کد وارد شده صحیح نمی باشد.'; + break; + case 614: + errorMessage = 'لطفا 2 دقیقه دیگر مجدد تلاش نمایید'; + break; + case 600: + errorMessage = 'رمز یکبارمصرف منقضی شده است.'; + break; + case 615: + errorMessage = 'تعداد درخواستهای شما بیشتر از حد مجاز است.'; + break; + case 612: + errorMessage = 'کد ملی وارد شده معتبر نمی باشد'; + break; + } + + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + + // If we don't have a valid error response, handle the 530 status code + if (error.response?.status === 530) { + throw new HttpException( + 'خطا در ارتباط با سرور احراز هویت', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + throw new HttpException( + error.response?.data?.errorMessage || + 'خطا در ارتباط با سرور احراز هویت', + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } else { + // Original non-SSO verification flow + if (!user.otp || !user.otpCreatedAt) + throw new HttpException('رمز یکبارمصرف یافت نشد', HttpStatus.BAD_REQUEST); + + const otpAgeMs = Date.now() - user.otpCreatedAt.getTime(); + if (otpAgeMs > 120000) { + await this.clearOtp(user.mobile); + throw new HttpException('رمز یکبارمصرف منقضی شده است.', HttpStatus.BAD_REQUEST); + } + + if (user.otpAttempts >= 3) { + throw new HttpException( + 'تعداد درخواستهای شما بیشتر از حد مجاز است.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + if (!UserModel.validateOtp(userData.otp, user.otp)) { + await this.user.updateOne( + { mobile: userData.mobile }, + { $inc: { otpAttempts: 1 } }, + ); + throw new HttpException('کد وارد شده صحیح نمی باشد.', HttpStatus.BAD_REQUEST); + } + + await this.clearOtp(user.mobile); + const accessToken = await this.generateJwtToken(user, Role.User); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', accessToken); + } + } catch (error) { + console.error('User Verify Error:', error); + + if (error instanceof HttpException) { + throw new BaseResponseDTO(error.getStatus(), error.message, null); + } + + if (error.response?.data) { + throw new BaseResponseDTO( + error.response.status || HttpStatus.INTERNAL_SERVER_ERROR, + error.response.data.errorMessage || 'خطا در احراز هویت', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطا در احراز هویت', + null, + ); + } + } + + async loginVerifyTest(userData: UserVerifyOtpDTO) { + try { + if (!userData.mobile || !userData.otp) + throw new HttpException('اطلاعات ورودی ناقص است', HttpStatus.BAD_REQUEST); + const user = await this.user + .findOne({ mobile: userData.mobile }) + .select('+otp +otpCreatedAt +otpAttempts +nationalCode'); + if (!user) + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + const accessToken = await this.generateJwtToken(user, Role.User); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', accessToken); + } catch (error) { + console.error('User Verify Error:', error); + + if (error instanceof HttpException) { + throw new BaseResponseDTO(error.getStatus(), error.message, null); + } + + if (error.response?.data) { + throw new BaseResponseDTO( + error.response.status || HttpStatus.INTERNAL_SERVER_ERROR, + error.response.data.errorMessage || 'خطا در احراز هویت', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطا در احراز هویت', + null, + ); + } + } + + async loginTamasino(userData:UserLoginDTO){ + try { + const now = new Date(); + const otp = this.generateOtp(); + // const message = `کاربر محترم رمز یکبار مصرف شما برای چت بات هولدینگ سرآمد\nCode: ${otp}`; +const url = `https://api.kavenegar.com/v1/${process.env.KN_KEY}/verify/lookup.json` + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + } + } catch (err) { + console.error('Login Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginSaramad(userData: UserLoginDTO) { + try { + const now = new Date(); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای چت بات هولدینگ سرآمد\nCode: ${otp}`; + + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + //* using nationalCode and mobile (sso service) + console.info(`Log in using TFA: ${userData.twoFactor}`); + console.info( + `Mobile: ${userData.mobile} , nationalCode: ${userData.nationalCode}`, + ); + if (process.env.SSO_ENABLED == 'true' && userData.twoFactor) { + console.info('here in sso enabled and two factor'); + if (!userData.nationalCode) + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + const ssoResponse = await axios.post( + process.env.SAMAN_SSO_URL, + { + nationalCode: userData.nationalCode, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), // ⚠️ Bypasses SSL verification + timeout: 15000, + }, + ); + if ( + ssoResponse.data.code !== 0 || + !ssoResponse.data.data?.phoneNumber + ) { + const errorMessage = + ssoResponse.data.errorMessage || 'ورود از طریق سامانه احراز هویت با خطا مواجه شد'; + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + console.info(`sms sent to ${userData.mobile} using SSO service `); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else if ( + process.env.SSO_ENABLED == 'true' && + !userData.twoFactor + ) { + //* using just mobile (notify service) + console.info('here in sso enabled and one factor in notify'); + const notify = await this.ssoService.notifyService( + userData.mobile, + message, + 'otp', + ); + console.info(notify); + console.info(`sms sent to ${userData.mobile} using notify service `); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else { + console.info('here in development'); + let sms = await this.smsService.smsSender(otp, userData.mobile); + console.log(sms); + console.info( + `sms sent to ${userData.mobile} using kavehnegar service `, + ); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + } catch (err) { + console.error('Login Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginAsia(userData: UserLoginDTO) { + try { + const now = new Date(); + const otp = this.generateOtp(); + const baseMessage = `کاربر محترم کد ورود شما برای بیمه آسیا`; + const fullMessage = `${baseMessage}\nCode: ${otp}`; + + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp, + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: false, + }); + } else { + user.otp = otp; + user.otpCreatedAt = now; + user.otpAttempts = 0; + await user.save(); + } + + const recipientsArr = [userData.mobile]; + + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'cache-control': 'no-cache', + }, + }; + + // Magfa basic auth for Asia Insurance – can be moved to env if needed + axios.defaults.headers.post['Authorization'] = + process.env.ASIA_MAGFA_BASIC_TOKEN || + 'Basic QmltZWFzaWEvTWFnZmE6X0JpbWVoXzEyOV8='; + + const body = { + senders: ['30007151'], + recipients: recipientsArr, + messages: [fullMessage], + }; + + const encodedUri = encodeURI( + 'https://sms.magfa.com/api/http/sms/v2/send', + ); + + const response = await axios.post(encodedUri, body, axiosConfig); + console.log('Asia Insurance SMS response:', response.data); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } catch (err) { + console.error('Login Asia Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'sms_service_timeout', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginV4(userData: UserLoginDTO) { + try { + const now = new Date(); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن بیمه سامان\nCode: ${otp}`; + + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + //* using nationalCode and mobile (sso service) + console.info(`Log in using TFA: ${userData.twoFactor}`); + console.info( + `Mobile: ${userData.mobile} , nationalCode: ${userData.nationalCode}`, + ); + if (process.env.SSO_ENABLED == 'true' && userData.twoFactor) { + console.info('here in sso enabled and two factor'); + if (!userData.nationalCode) + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + const ssoResponse = await axios.post( + process.env.SAMAN_SSO_URL, + { + nationalCode: userData.nationalCode, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), // ⚠️ Bypasses SSL verification + timeout: 15000, + }, + ); + if ( + ssoResponse.data.code !== 0 || + !ssoResponse.data.data?.phoneNumber + ) { + const errorMessage = + ssoResponse.data.errorMessage || 'ورود از طریق سامانه احراز هویت با خطا مواجه شد'; + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + console.info(`sms sent to ${userData.mobile} using SSO service `); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else if ( + process.env.SSO_ENABLED == 'true' && + !userData.twoFactor + ) { + //* using just mobile (notify service) + console.info('here in sso enabled and one factor in notify'); + const notify = await this.ssoService.notifyService( + userData.mobile, + message, + 'otp', + ); + console.info(notify); + console.info(`sms sent to ${userData.mobile} using notify service `); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else { + console.info('here in development'); + let sms = await this.smsService.smsSender(otp, userData.mobile); + console.log(sms); + console.info( + `sms sent to ${userData.mobile} using kavehnegar service `, + ); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + } catch (err) { + console.error('Login Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async adminLogin(admin: AdminLoginDTO) { + let { username, password } = admin; + let findUsername = await this.adminService.findOneAdmin({ + username: username, + isActive: true, + }); + + if (!findUsername) { + throw new UnauthorizedException('نام کاربری یا رمز عبور نادرست است یا حساب غیرفعال می‌باشد'); + } + + let hashedRawPassword = crypto + .createHash('sha256') + .update(password) + .digest('hex'); + if (hashedRawPassword === findUsername.password) { + return this.generateJwtToken(findUsername, Role.Admin); + } else { + return null; + } + } + + async adminValidate(admin: AdminLoginDTO) { + try { + let validateAdmin = await this.adminService.findOneAdmin({ + username: admin.username, + }); + if (!validateAdmin) { + throw new HttpException('ادمین یافت نشد', HttpStatus.NOT_FOUND); + } + return validateAdmin; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findAdminResetToken(token: string) { + try { + let findToken = await this.adminService.findOneAdmin({ + resetToken: token, + }); + if (!findToken) { + throw new HttpException('ادمین یافت نشد', HttpStatus.NOT_FOUND); + } + return findToken; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async forgetPassword(email: ForgetPasswordDTO) { + try { + // TODO Email Service + let userHasExist = await this.adminService.findOneAdmin({ + username: email.username, + }); + if (!userHasExist) + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + let token = this.jwtService.sign( + { username: userHasExist.username, time: new Date() }, + { + secret: process.env.auth_jwt_forget_password, + }, + ); + userHasExist.resetToken = token; + await userHasExist.save(); + return new BaseResponseDTO(HttpStatus.ACCEPTED, 'SUCCESS', { + link: `${process.env.BASEURL}?token=${token}`, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async resetPassword(DTO: ResetPasswordDTO, identity: Identity) { + try { + const email = await this.decodeConfirmationToken(DTO.resetToken); + + const user = await this.adminService.findOneAdmin({ username: email }); + if (!user) { + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + } + + user.password = crypto + .createHash('sha256') + .update(DTO.newPassword) + .digest('hex'); + user.resetToken = null; + await user.save(); + return new BaseResponseDTO( + HttpStatus.ACCEPTED, + 'SUCCESS', + 'PASSWORD_CHANGED', + ); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + public async decodeConfirmationToken(token: string) { + try { + const payload = await this.jwtService.verify(token, { + secret: process.env.auth_jwt_forget_password, + }); + + if ('username' in payload) { + return payload.username; + } + throw new BadRequestException('درخواست نامعتبر است'); + } catch (error) { + if (error?.name === 'TokenExpiredError') { + throw new HttpException('توکن منقضی شده است', HttpStatus.UNAUTHORIZED); + } + throw new BadRequestException('توکن تایید نامعتبر است'); + } + } + + async generateJwtToken(userData, role: Role) { + // Get expiresIn from environment with fallback + const expiresInProduction = process.env.ACCESS_TOKEN_EXPIRE_TIME_PRODUCTION || '12h'; + const expiresInDevelopment = process.env.ACCESS_TOKEN_EXPIRE_TIME_DEVELOPMENT || '1d'; + + // Remove quotes if present (env vars might have quotes) + const cleanExpiresIn = (value: string) => { + if (!value) return '1d'; // Default fallback + return value.replace(/^['"]|['"]$/g, ''); // Remove surrounding quotes + }; + + const config = { + secret: process.env.auth_jwt_secret, + expiresIn: cleanExpiresIn( + process.env.NODE_ENV === 'production' + ? expiresInProduction + : expiresInDevelopment, + ), + }; + + if (role === Role.User) { + const token = await this.jwtService.signAsync( + { + _id: userData._id, + role: userData.role || role, + mobile: userData.mobile, + createdAt: userData.createdAt, + updatedAt: userData.updatedAt, + }, + config, + ); + + //encrypt token + return EncryptionHelper.encrypt(token); + } else { + const data = { + _id: userData._id, + role: userData.role || role, + mobile: userData.mobile, + name: userData.name, + family: userData.family, + username: userData.username, + createdAt: userData.createdAt, + updatedAt: userData.updatedAt, + }; + const token = await this.jwtService.signAsync(data, config); + //encrypt token + return EncryptionHelper.encrypt(token); + } + } + + // auth.service.ts (or wherever your logout method is) + + async logout(req, res: any) { + try { + const token = req.headers['authorization']?.split(' ')[1]; + if (!token) throw new UnauthorizedException('توکن یافت نشد'); + + const decryptedToken = EncryptionHelper.decrypt(token); + const decoded = this.jwtService.verify(decryptedToken, { + secret: process.env.auth_jwt_secret, + }) as { + _id: string; + role: string; + exp: number; + iat: number; + }; + + const currentTime = Math.floor(Date.now() / 1000); + const ttlInSeconds = decoded.exp - currentTime; + if (ttlInSeconds > 0) { + await this.redisService.blacklistToken(token, ttlInSeconds); // ✅ Using service method + } + + const isStaff = decoded.role && decoded.role !== Role.User; + if (isStaff) { + await this.auditLogService.log({ + action: 'auth.staff_logout', + resource: 'Admin', + resourceId: decoded._id, + userId: decoded._id, + metadata: { role: decoded.role }, + }); + } + + return { message: 'خروج با موفقیت انجام شد' }; + } catch (err) { + console.log(err); + throw new BaseResponseDTO( + err.status || 500, + err.message || 'خروج با خطا مواجه شد', + null, + ); + } + } +} diff --git a/src/auth/guards/admin.guard.ts b/src/auth/guards/admin.guard.ts new file mode 100644 index 0000000..448dee2 --- /dev/null +++ b/src/auth/guards/admin.guard.ts @@ -0,0 +1,274 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, + ForbiddenException, + HttpStatus, + Optional, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { rateLimit } from 'express-rate-limit'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { EncryptionHelper } from 'src/common/tools/encryption-helper'; +import { SKIP_ADMIN_RATE_LIMIT_KEY } from 'src/common/decorators/skip-admin-rate-limit.decorator'; +import { PERMISSIONS_KEY } from 'src/common/decorators/permission.decorator'; +import { ROLES_KEY } from 'src/common/decorators/role.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { isOwnerRole } from 'src/common/types/role.type'; +import { PermissionsService } from 'src/acl/permissions.service'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class AdminGuard implements CanActivate { + private readonly limiter: ReturnType; + + constructor( + private readonly authService: AuthService, + private readonly reflector: Reflector, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + @Optional() private readonly permissionsService?: PermissionsService, + @Optional() private readonly auditLogService?: AuditLogService, + ) { + const isProduction = this.configService.get('NODE_ENV') === 'production'; + + this.limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: isProduction ? 50 : 100, // Stricter in production + standardHeaders: false, + legacyHeaders: false, + skip: () => !isProduction, // Skip rate limiting in non-production + keyGenerator: (req) => { + // Use admin ID if authenticated, otherwise use IP + return req['admin']?._id || req.ip; + }, + handler: (req, res) => { + res.status(429).json({ + statusCode: 429, + message: isProduction + ? 'Too many admin requests' + : 'Dev Mode: Admin rate limit warning (not enforced)', + }); + }, + }); + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + // Check if the Admin rate limit should be skipped for this handler/class + const skipAdminRateLimit = this.reflector.getAllAndOverride( + SKIP_ADMIN_RATE_LIMIT_KEY, + [context.getHandler(), context.getClass()], + ); + + // Apply rate limiting only if not skipped + if (!skipAdminRateLimit && !(await this.applyRateLimit(request))) { + return false; + } + + if (this.isLoginPath(request)) { + return this.handleLogin(request); + } else if (this.isForgetPath(request)) { + return this.handleResetPath(request); + } else { + return this.handleAuthentication( + request, + requiredRoles, + requiredPermissions, + ); + } + } + + private async applyRateLimit(request: Request): Promise { + return new Promise((resolve) => { + const mockResponse = { + status: (code) => ({ + json: (data) => { + request.res.status(code).json(data); + resolve(false); + }, + }), + } as any; + + this.limiter(request, mockResponse, (err?: unknown) => { + if (err) { + return resolve(false); + } + resolve(true); + }); + }); + } + + private async handleResetPath(request: Request) { + if (request.url == '/auth/admin-reset-password') { + let resetTokenFromAdmin = await this.authService.findAdminResetToken( + request.body.resetToken, + ); + if (resetTokenFromAdmin) { + request['admin'] = resetTokenFromAdmin; + } + return true; + } else { + const isAdmin = await this.authService.adminValidate(request.body); + if (isAdmin) { + request['admin'] = isAdmin; + } + return true; + } + } + + private async handleLogin(request: Request): Promise { + try { + const isAdmin = await this.authService.adminValidate(request.body); + if (isAdmin) { + request['admin'] = isAdmin; + const token = await this.authService.adminLogin(request.body); + if (token) { + request['token'] = token; + await this.auditLogService?.logHttpRequest( + 'auth.staff_login_success', + request, + { + userId: String(isAdmin._id), + resource: 'Admin', + resourceId: String(isAdmin._id), + metadata: { role: isAdmin.role, username: isAdmin.username }, + }, + ); + return true; + } else { + await this.auditLogService?.logHttpRequest( + 'auth.staff_login_failure', + request, + { + resource: 'Admin', + metadata: { username: request.body?.username, reason: 'bad_password' }, + statusCode: 401, + }, + ); + throw new BaseResponseDTO( + HttpStatus.UNAUTHORIZED, + 'رمز عبور نادرست است', + null, + ); + } + } else { + await this.auditLogService?.logHttpRequest( + 'auth.staff_login_failure', + request, + { + resource: 'Admin', + metadata: { username: request.body?.username, reason: 'not_found' }, + statusCode: 401, + }, + ); + throw new BaseResponseDTO( + HttpStatus.UNAUTHORIZED, + 'اطلاعات ورود نامعتبر است', + null, + ); + } + } catch (err) { + throw new UnauthorizedException(err.message || 'عدم احراز هویت'); + } + } + + private async handleAuthentication( + request: Request, + requiredRoles: string[] | undefined, + requiredPermissions: Permission[] | undefined, + ): Promise { + const token = this.extractTokenFromHeader(request); + + if (!token) { + throw new UnauthorizedException('توکن الزامی است'); + } + + try { + const decryptedToken = EncryptionHelper.decrypt(token); + let payload = await this.jwtService.verifyAsync(decryptedToken, { + secret: process.env.auth_jwt_secret, + }); + payload.userData = payload; + + // Owner bypasses role/permission allowlists but must still be active in DB + if (isOwnerRole(payload.userData.role)) { + if (this.permissionsService) { + const effective = + await this.permissionsService.getEffectivePermissionsForAdminId( + String(payload.userData._id), + ); + if (effective.size === 0) { + throw new UnauthorizedException('حساب مالک غیرفعال است یا وجود ندارد'); + } + } + request['admin'] = payload; + return true; + } + + if (requiredPermissions?.length) { + if (!this.permissionsService) { + throw new ForbiddenException( + 'سرویس دسترسی‌ها در دسترس نیست', + ); + } + const effective = + await this.permissionsService.getEffectivePermissionsForAdminId( + String(payload.userData._id), + ); + const allowed = requiredPermissions.some((p) => effective.has(p)); + if (!allowed) { + throw new ForbiddenException('دسترسی مجاز نیست: مجوز کافی ندارید'); + } + } else if (requiredRoles?.length) { + if (!this.hasRequiredRole(payload.userData.role, requiredRoles)) { + throw new ForbiddenException('دسترسی مجاز نیست: نقش کافی ندارید'); + } + } + + request['admin'] = payload; + return true; + } catch (err) { + if (err instanceof ForbiddenException) { + throw err; + } + throw new UnauthorizedException(err.message || 'توکن نامعتبر است'); + } + } + + private isLoginPath(request: Request): boolean { + return /\b(login)\b/i.test(request.route?.path || ''); + } + + private isForgetPath(request: Request): boolean { + return /\b(forget|reset)\b/i.test(request.route?.path || ''); + } + + private extractTokenFromHeader(request: Request): string | undefined { + const authorization = request.headers?.authorization || ''; + const [type, token] = authorization.split(' '); + return type === 'Bearer' ? token : undefined; + } + + private hasRequiredRole( + userRole: string | string[], + requiredRoles: string[], + ): boolean { + const roles = Array.isArray(userRole) ? userRole : [userRole]; + return requiredRoles.some((role) => roles.includes(role)); + } +} diff --git a/src/auth/guards/api-key-auth.guard.ts b/src/auth/guards/api-key-auth.guard.ts new file mode 100644 index 0000000..e71ad6a --- /dev/null +++ b/src/auth/guards/api-key-auth.guard.ts @@ -0,0 +1,38 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common'; +import { Request } from 'express'; +import { ClientManagementService } from 'src/client-management/client-management.service'; + +@Injectable() +export class ApiKeyAuthGuard implements CanActivate { + constructor(private readonly clientManagementService: ClientManagementService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + // Allow API key to be passed via x-api-key header or as a query parameter for Swagger compatibility + const apiKey = (request.headers['x-api-key'] || request.query['apiKey']) as string; + + if (!apiKey) { + throw new UnauthorizedException('کلید API یافت نشد'); + } + + const client = await this.clientManagementService.findClientByApiKey(apiKey); + + if (!client) { + throw new UnauthorizedException('کلید API نامعتبر است'); + } + + if (client.status !== 'active') { + throw new ForbiddenException('کلاینت غیرفعال است'); + } + + // Attach client information to the request object + request['client'] = { + apiKey: client.apiKey, + name: client.name, + enName: client.enName, + status: client.status, + }; + + return true; + } +} \ No newline at end of file diff --git a/src/auth/guards/ip-rate-limiter.guard.ts b/src/auth/guards/ip-rate-limiter.guard.ts new file mode 100644 index 0000000..118ceff --- /dev/null +++ b/src/auth/guards/ip-rate-limiter.guard.ts @@ -0,0 +1,48 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; +import { rateLimit } from 'express-rate-limit'; + +@Injectable() +export class IpRateLimiterGuard implements CanActivate { + private readonly limiter: ReturnType; + + constructor(private readonly configService: ConfigService) { + const isProduction = this.configService.get('NODE_ENV') === 'production'; + this.limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: isProduction ? 50 : 100, // Stricter in production + standardHeaders: false, + legacyHeaders: false, + skip: () => + !isProduction && + this.configService.get('DISABLE_RATE_LIMIT') === 'true', // Skip in dev if needed + keyGenerator: (req) => { + // Use IP or user ID if available + return isProduction ? req.ip : ''; // Less strict in development + }, + handler: (req, res) => { + res.status(429).json({ + statusCode: 429, + message: isProduction + ? 'Too many requests' + : 'Dev Mode: Rate limit warning (not enforced)', + }); + }, + }); + } + + async canActivate(context: ExecutionContext): Promise { + const httpContext = context.switchToHttp(); + const req = httpContext.getRequest(); + const res = httpContext.getResponse(); + return new Promise((resolve) => { + this.limiter(req, res, (err?: unknown) => { + if (err) { + return resolve(false); + } + resolve(true); + }); + }); + } +} diff --git a/src/auth/local.strategy.ts b/src/auth/local.strategy.ts new file mode 100644 index 0000000..96ef945 --- /dev/null +++ b/src/auth/local.strategy.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { AuthService } from './auth.service'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor(private readonly authService: AuthService) { + super(); + } + + // async validate(username: string, password: string): Promise { + // const user = await this.authService.validateUser(username, password); + + // return user; + // } +} diff --git a/src/auth/models/identity.model.ts b/src/auth/models/identity.model.ts new file mode 100644 index 0000000..6e5e65e --- /dev/null +++ b/src/auth/models/identity.model.ts @@ -0,0 +1,14 @@ +import { UserModel } from 'src/database/model/user.model'; + +export class Identity { + user: UserModel; + public get isAuthenticated(): boolean { + return !!this.user; + } +} +// export class AdminIdentity { +// admin: AdminModel; +// public get isAuthenticated(): boolean { +// return !!this.admin; +// } +// } diff --git a/src/business-hours/business-hours.controller.ts b/src/business-hours/business-hours.controller.ts new file mode 100644 index 0000000..2236837 --- /dev/null +++ b/src/business-hours/business-hours.controller.ts @@ -0,0 +1,156 @@ +import { + Controller, + Get, + Post, + Put, + Patch, + Body, + Param, + UseGuards, + HttpStatus, + HttpException, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiTags, + ApiParam, + ApiResponse, + ApiBody, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminModel } from 'src/database/model/admin.model'; +import { BusinessHoursService } from './business-hours.service'; +import { CreateBusinessHoursDto } from './dto/create-business-hours.dto'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Public } from 'src/auth/auth.decorator'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.BusinessHoursManage) +@ApiTags('business-hours') +@Controller('admin/business-hours') +export class BusinessHoursController { + constructor(private readonly businessHoursService: BusinessHoursService) {} + + @Get() + @ApiOperation({ summary: 'Get all business hours configurations' }) + @ApiResponse({ status: 200, description: 'List of all configurations' }) + async getAllConfigs(@AdminIdentity() admin: AdminModel) { + try { + const configs = await this.businessHoursService.getAllConfigs(); + return new BaseResponseDTO( + HttpStatus.OK, + 'Business hours configurations retrieved successfully', + configs, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to retrieve configurations', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Get('status') + @Public() + @ApiOperation({ summary: 'Get current business hours status (public)' }) + @ApiResponse({ status: 200, description: 'Current status' }) + async getStatus() { + try { + const status = await this.businessHoursService.isOpen(); + const nextOpen = await this.businessHoursService.getNextOpen(); + const config = await this.businessHoursService.getActiveConfig(); + + return new BaseResponseDTO(HttpStatus.OK, 'Status retrieved successfully', { + open: status.open, + nextOpen: nextOpen?.toISOString() || null, + timezone: config?.timezone || null, + currentWindow: status.currentWindow || null, + }); + } catch (error) { + throw new HttpException( + error.message || 'Failed to get status', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Post() + @ApiOperation({ summary: 'Create a new business hours configuration' }) + @ApiBody({ type: CreateBusinessHoursDto }) + @ApiResponse({ status: 201, description: 'Configuration created' }) + async createConfig( + @Body() createDto: CreateBusinessHoursDto, + @AdminIdentity() admin: AdminModel, + ) { + try { + const config = await this.businessHoursService.createConfig( + createDto as any, + admin.username, + ); + return new BaseResponseDTO( + HttpStatus.CREATED, + 'Business hours configuration created successfully', + config, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to create configuration', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Put(':id') + @ApiOperation({ summary: 'Update an existing business hours configuration' }) + @ApiParam({ name: 'id', description: 'Configuration ID' }) + @ApiBody({ type: CreateBusinessHoursDto }) + @ApiResponse({ status: 200, description: 'Configuration updated' }) + async updateConfig( + @Param('id') id: string, + @Body() updateDto: CreateBusinessHoursDto, + @AdminIdentity() admin: AdminModel, + ) { + try { + const config = await this.businessHoursService.updateConfig( + id, + updateDto as any, + admin.username, + ); + return new BaseResponseDTO( + HttpStatus.OK, + 'Business hours configuration updated successfully', + config, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to update configuration', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Patch(':id/deactivate') + @ApiOperation({ summary: 'Deactivate a business hours configuration' }) + @ApiParam({ name: 'id', description: 'Configuration ID' }) + @ApiResponse({ status: 200, description: 'Configuration deactivated' }) + async deactivateConfig(@Param('id') id: string) { + try { + await this.businessHoursService.deactivateConfig(id); + return new BaseResponseDTO( + HttpStatus.OK, + 'Business hours configuration deactivated successfully', + null, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to deactivate configuration', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/business-hours/business-hours.module.ts b/src/business-hours/business-hours.module.ts new file mode 100644 index 0000000..f9daed9 --- /dev/null +++ b/src/business-hours/business-hours.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { DatabaseModule } from 'src/database/database.module'; +import { + BusinessHoursModel, + BusinessHoursSchema, +} from 'src/database/model/business-hours.model'; +import { BusinessHoursController } from './business-hours.controller'; +import { BusinessHoursService } from './business-hours.service'; +import { StatusController } from './status.controller'; + +@Module({ + imports: [ + DatabaseModule, + MongooseModule.forFeature([ + { name: BusinessHoursModel.name, schema: BusinessHoursSchema }, + ]), + ], + controllers: [BusinessHoursController, StatusController], + providers: [BusinessHoursService], + exports: [BusinessHoursService], +}) +export class BusinessHoursModule {} + diff --git a/src/business-hours/business-hours.service.ts b/src/business-hours/business-hours.service.ts new file mode 100644 index 0000000..782b167 --- /dev/null +++ b/src/business-hours/business-hours.service.ts @@ -0,0 +1,422 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + BusinessHoursModel, + BusinessHoursConfig, + WeeklyWindow, + Exception, + TimeInterval, +} from 'src/database/model/business-hours.model'; +import { RedisService } from 'src/common/helpers/redis.service'; + +export interface BusinessHoursStatus { + open: boolean; + reason?: string; + nextOpen?: Date; + currentWindow?: TimeInterval; +} + +@Injectable() +export class BusinessHoursService implements OnModuleInit { + private readonly logger = new Logger(BusinessHoursService.name); + private cachedConfig: BusinessHoursConfig | null = null; + private cacheLoadedAt: Date | null = null; + private readonly CACHE_TTL_MS = 60 * 1000; // 1 minute + private readonly REDIS_CHANNEL = 'cfg:business-hours:update'; + + constructor( + @InjectModel(BusinessHoursModel.name) + private readonly businessHoursModel: Model, + private readonly redisService: RedisService, + ) {} + + async onModuleInit() { + // Subscribe to Redis pub/sub for cache invalidation + try { + const client = this.redisService.getClient(); + // Use duplicate client for subscription to avoid blocking + const subClient = client.duplicate(); + await subClient.subscribe(this.REDIS_CHANNEL); + subClient.on('message', (channel, message) => { + if (channel === this.REDIS_CHANNEL) { + this.logger.log('Received cache invalidation signal, clearing cache'); + this.cachedConfig = null; + this.cacheLoadedAt = null; + } + }); + this.logger.log('Subscribed to business hours cache invalidation channel'); + } catch (error) { + this.logger.error('Failed to subscribe to Redis channel', error); + } + } + + /** + * Get the active business hours configuration + */ + async getActiveConfig(): Promise { + // Check cache first + if ( + this.cachedConfig && + this.cacheLoadedAt && + Date.now() - this.cacheLoadedAt.getTime() < this.CACHE_TTL_MS + ) { + return this.cachedConfig; + } + + // Load from DB + const activeConfig = await this.businessHoursModel + .findOne({ isActive: true }) + .lean() + .exec(); + + if (!activeConfig) { + this.logger.warn('No active business hours configuration found'); + return null; + } + + this.cachedConfig = activeConfig.config; + this.cacheLoadedAt = new Date(); + return this.cachedConfig; + } + + /** + * Check if business hours are currently open + */ + async isOpen(now?: Date): Promise { + const config = await this.getActiveConfig(); + if (!config) { + return { + open: false, + reason: 'No business hours configuration found', + }; + } + + // Check global toggle + if (!config.globalToggle) { + return { + open: false, + reason: 'Online conversation is globally disabled', + }; + } + + const checkTime = now || new Date(); + const tzTime = this.convertToTimezone(checkTime, config.timezone); + const dateStr = this.formatDate(tzTime); + const timeStr = this.formatTime(tzTime); + const dayOfWeek = this.getDayOfWeek(tzTime); + + // Check exceptions first + const exception = config.exceptions?.find((e) => e.date === dateStr); + if (exception) { + if (exception.intervals.length === 0) { + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: exception.reason || 'Closed due to exception', + nextOpen, + }; + } + // Check if current time is within exception intervals + const isInInterval = this.isTimeInIntervals(timeStr, exception.intervals); + if (isInInterval) { + return { open: true }; + } + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: exception.reason || 'Closed due to exception', + nextOpen, + }; + } + + // Check weekly windows + const weeklyWindow = config.weeklyWindows?.find( + (w) => w.day === dayOfWeek, + ); + if (!weeklyWindow || weeklyWindow.intervals.length === 0) { + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: 'Online conversation is not available on this day', + nextOpen, + }; + } + + // Check if current time is within any interval + const isInInterval = this.isTimeInIntervals( + timeStr, + weeklyWindow.intervals, + ); + if (isInInterval) { + const currentWindow = this.getCurrentWindow( + timeStr, + weeklyWindow.intervals, + ); + return { open: true, currentWindow }; + } + + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: 'Online conversation is not available at this time', + nextOpen, + }; + } + + /** + * Get the next opening time + */ + async getNextOpen(now?: Date): Promise { + const config = await this.getActiveConfig(); + if (!config || !config.globalToggle) { + return null; + } + + const checkTime = now || new Date(); + const tzTime = this.convertToTimezone(checkTime, config.timezone); + const maxDaysToCheck = 14; // Check up to 2 weeks ahead + + for (let dayOffset = 0; dayOffset < maxDaysToCheck; dayOffset++) { + const checkDate = new Date(tzTime); + checkDate.setDate(checkDate.getDate() + dayOffset); + const dateStr = this.formatDate(checkDate); + const dayOfWeek = this.getDayOfWeek(checkDate); + + // Check exceptions first + const exception = config.exceptions?.find((e) => e.date === dateStr); + if (exception) { + if (exception.intervals.length > 0) { + // Find first interval for this exception + const firstInterval = exception.intervals[0]; + const openTime = this.parseTime(firstInterval.start); + checkDate.setHours(openTime.hours, openTime.minutes, 0, 0); + return this.convertFromTimezone(checkDate, config.timezone); + } + continue; // Skip this day if exception closes it + } + + // Check weekly windows + const weeklyWindow = config.weeklyWindows?.find( + (w) => w.day === dayOfWeek, + ); + if (weeklyWindow && weeklyWindow.intervals.length > 0) { + const firstInterval = weeklyWindow.intervals[0]; + const openTime = this.parseTime(firstInterval.start); + checkDate.setHours(openTime.hours, openTime.minutes, 0, 0); + + // If checking today and time has passed, check tomorrow + if (dayOffset === 0 && checkDate <= tzTime) { + continue; + } + + return this.convertFromTimezone(checkDate, config.timezone); + } + } + + return null; + } + + /** + * Get all business hours configurations + */ + async getAllConfigs() { + return this.businessHoursModel.find().sort({ createdAt: -1 }).lean().exec(); + } + + /** + * Create a new business hours configuration + */ + async createConfig( + config: BusinessHoursConfig, + adminUsername: string, + ): Promise { + // Deactivate all existing configs + await this.businessHoursModel.updateMany( + {}, + { $set: { isActive: false } }, + ); + + // Create new active config + const newConfig = new this.businessHoursModel({ + config, + createdBy: [adminUsername], + updatedBy: adminUsername, + isActive: true, + }); + + const saved = await newConfig.save(); + + // Invalidate cache and notify other instances + this.cachedConfig = null; + this.cacheLoadedAt = null; + await this.publishCacheInvalidation(); + + return saved; + } + + /** + * Update an existing business hours configuration + */ + async updateConfig( + id: string, + config: BusinessHoursConfig, + adminUsername: string, + ): Promise { + const existing = await this.businessHoursModel.findById(id); + if (!existing) { + throw new Error('Business hours configuration not found'); + } + + // If activating this config, deactivate all others + if (existing.isActive || config.globalToggle) { + await this.businessHoursModel.updateMany( + { _id: { $ne: id } }, + { $set: { isActive: false } }, + ); + } + + // Update the config + existing.config = config; + existing.updatedBy = adminUsername; + if (!existing.createdBy.includes(adminUsername)) { + existing.createdBy.push(adminUsername); + } + existing.isActive = true; // Activate when updated + + const saved = await existing.save(); + + // Invalidate cache and notify other instances + this.cachedConfig = null; + this.cacheLoadedAt = null; + await this.publishCacheInvalidation(); + + return saved; + } + + /** + * Deactivate a business hours configuration + */ + async deactivateConfig(id: string): Promise { + const existing = await this.businessHoursModel.findById(id); + if (!existing) { + throw new Error('Business hours configuration not found'); + } + + existing.isActive = false; + await existing.save(); + + // Invalidate cache and notify other instances + this.cachedConfig = null; + this.cacheLoadedAt = null; + await this.publishCacheInvalidation(); + } + + /** + * Publish cache invalidation event to Redis + */ + private async publishCacheInvalidation(): Promise { + try { + const client = this.redisService.getClient(); + await client.publish( + this.REDIS_CHANNEL, + JSON.stringify({ timestamp: Date.now() }), + ); + } catch (error) { + this.logger.error('Failed to publish cache invalidation', error); + } + } + + // Helper methods for timezone and time calculations + + private convertToTimezone(date: Date, timezone: string): Date { + // Simple timezone offset calculation + // For production, use a proper timezone library like luxon or date-fns-tz + // This is a simplified version for common timezones + const offsetMap: Record = { + 'Asia/Tehran': 3.5, // UTC+3:30 + 'UTC': 0, + }; + + const offset = offsetMap[timezone] || 0; + const utcTime = date.getTime() + date.getTimezoneOffset() * 60000; + return new Date(utcTime + offset * 3600000); + } + + private convertFromTimezone(date: Date, timezone: string): Date { + const offsetMap: Record = { + 'Asia/Tehran': 3.5, + 'UTC': 0, + }; + + const offset = offsetMap[timezone] || 0; + const utcTime = date.getTime() - offset * 3600000; + return new Date(utcTime - new Date().getTimezoneOffset() * 60000); + } + + private formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + private formatTime(date: Date): string { + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; + } + + private getDayOfWeek(date: Date): WeeklyWindow['day'] { + const days: WeeklyWindow['day'][] = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + return days[date.getDay()]; + } + + private parseTime(timeStr: string): { hours: number; minutes: number } { + const [hours, minutes] = timeStr.split(':').map(Number); + return { hours, minutes }; + } + + private isTimeInIntervals( + timeStr: string, + intervals: TimeInterval[], + ): boolean { + const { hours: h, minutes: m } = this.parseTime(timeStr); + const currentMinutes = h * 60 + m; + + return intervals.some((interval) => { + const start = this.parseTime(interval.start); + const end = this.parseTime(interval.end); + const startMinutes = start.hours * 60 + start.minutes; + const endMinutes = end.hours * 60 + end.minutes; + + return currentMinutes >= startMinutes && currentMinutes < endMinutes; + }); + } + + private getCurrentWindow( + timeStr: string, + intervals: TimeInterval[], + ): TimeInterval | undefined { + const { hours: h, minutes: m } = this.parseTime(timeStr); + const currentMinutes = h * 60 + m; + + return intervals.find((interval) => { + const start = this.parseTime(interval.start); + const end = this.parseTime(interval.end); + const startMinutes = start.hours * 60 + start.minutes; + const endMinutes = end.hours * 60 + end.minutes; + + return currentMinutes >= startMinutes && currentMinutes < endMinutes; + }); + } +} + diff --git a/src/business-hours/dto/create-business-hours.dto.ts b/src/business-hours/dto/create-business-hours.dto.ts new file mode 100644 index 0000000..457eaa4 --- /dev/null +++ b/src/business-hours/dto/create-business-hours.dto.ts @@ -0,0 +1,190 @@ +import { + IsString, + IsBoolean, + IsArray, + ValidateNested, + IsOptional, + IsIn, + Matches, + IsNotEmpty, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class TimeIntervalDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'Start time in HH:mm format (24-hour)', + example: '09:00', + pattern: '^([0-1][0-9]|2[0-3]):[0-5][0-9]$', + }) + @IsString() + @Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/, { + message: 'Time must be in HH:mm format', + }) + start: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'End time in HH:mm format (24-hour)', + example: '19:30', + pattern: '^([0-1][0-9]|2[0-3]):[0-5][0-9]$', + }) + @IsString() + @Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/, { + message: 'Time must be in HH:mm format', + }) + end: string; +} + +export class WeeklyWindowDto { + @ApiProperty({ + required: true, + type: 'string', + enum: ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + description: 'Day of the week', + example: 'saturday', + }) + @IsString() + @IsIn([ + 'saturday', + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + ]) + day: string; + + @ApiProperty({ + required: true, + type: [TimeIntervalDto], + description: 'Array of time intervals for this day (can have multiple intervals)', + example: [{ start: '09:00', end: '19:30' }], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TimeIntervalDto) + intervals: TimeIntervalDto[]; +} + +export class ExceptionDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'Date in YYYY-MM-DD format for exception (holiday, special day, etc.)', + example: '2025-03-20', + pattern: '^\\d{4}-\\d{2}-\\d{2}$', + }) + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Date must be in YYYY-MM-DD format', + }) + date: string; + + @ApiProperty({ + required: true, + type: [TimeIntervalDto], + description: 'Array of time intervals for this exception date. Empty array means closed all day.', + example: [], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TimeIntervalDto) + intervals: TimeIntervalDto[]; + + @ApiProperty({ + required: false, + type: 'string', + description: 'Reason for this exception (e.g., "Holiday Nowruz", "Special hours")', + example: 'Holiday Nowruz', + }) + @IsOptional() + @IsString() + reason?: string; +} + +export class CreateBusinessHoursDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'IANA timezone identifier (e.g., Asia/Tehran, UTC)', + example: 'Asia/Tehran', + }) + @IsString() + @IsNotEmpty() + timezone: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: 'Master switch to enable/disable online conversations globally', + example: true, + }) + @IsBoolean() + globalToggle: boolean; + + @ApiProperty({ + required: true, + type: [WeeklyWindowDto], + description: 'Weekly schedule with time windows for each day', + example: [ + { day: 'saturday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'sunday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'monday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'tuesday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'wednesday', intervals: [{ start: '08:00', end: '19:00' }] }, + { day: 'thursday', intervals: [{ start: '08:00', end: '12:00' }] }, + { day: 'friday', intervals: [] }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WeeklyWindowDto) + weeklyWindows: WeeklyWindowDto[]; + + @ApiProperty({ + required: false, + type: [ExceptionDto], + description: 'Date-specific exceptions (holidays, special hours). Empty array means closed.', + example: [ + { date: '2025-03-20', intervals: [], reason: 'Holiday Nowruz' }, + { date: '2025-03-21', intervals: [{ start: '10:00', end: '14:00' }], reason: 'Special hours' }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ExceptionDto) + @IsOptional() + exceptions?: ExceptionDto[]; + + @ApiProperty({ + required: false, + description: 'Policy for handling sessions when business hours close', + example: { onClose: 'allow_existing_until_end' }, + type: Object, + }) + @IsOptional() + policy?: { + onClose: 'allow_existing_until_end' | 'hard_close'; + }; + + @ApiProperty({ + required: false, + description: 'Custom message template for closed status', + example: { + key: 'errors.online_conversation_unavailable', + default: 'Online conversation is not available now. Next opening: {{nextOpen}}', + }, + type: Object, + }) + @IsOptional() + messageTemplate?: { + key: string; + default: string; + }; +} + diff --git a/src/business-hours/guards/business-hours.guard.ts b/src/business-hours/guards/business-hours.guard.ts new file mode 100644 index 0000000..d0fb9d2 --- /dev/null +++ b/src/business-hours/guards/business-hours.guard.ts @@ -0,0 +1,44 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { Socket } from 'socket.io'; +import { BusinessHoursService } from '../business-hours.service'; +import { createSocketResponse } from 'src/socket/support-management/dto/eventPayloads.dto'; +import { HttpStatus } from '@nestjs/common'; + +@Injectable() +export class BusinessHoursGuard implements CanActivate { + constructor(private readonly businessHoursService: BusinessHoursService) {} + + async canActivate(context: ExecutionContext): Promise { + const client = context.switchToWs().getClient(); + const status = await this.businessHoursService.isOpen(); + const nextOpen = await this.businessHoursService.getNextOpen(); + + if (!status.open) { + const message = + status.reason || + 'Online conversation is not available now. Please try again later.'; + + const errorResponse = createSocketResponse( + 'ONLINE_CONVERSATION_DISABLED', + { + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }, + message, + HttpStatus.SERVICE_UNAVAILABLE, + ); + + client.emit('response', errorResponse); + throw new WsException({ + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }); + } + + return true; + } +} + diff --git a/src/business-hours/status.controller.ts b/src/business-hours/status.controller.ts new file mode 100644 index 0000000..32cc2b0 --- /dev/null +++ b/src/business-hours/status.controller.ts @@ -0,0 +1,100 @@ +import { Controller, Get, HttpStatus, HttpException } from '@nestjs/common'; +import { ApiOperation, ApiTags, ApiResponse } from '@nestjs/swagger'; +import { Public } from 'src/auth/auth.decorator'; +import { BusinessHoursService } from './business-hours.service'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { BusinessHoursConfig, WeeklyWindow } from 'src/database/model/business-hours.model'; + +interface WeeklyWindowResponse { + day: string; + dayTranslation: string; + from: string; + to: string; +} + +@Public() +@ApiTags('status') +@Controller('status') +export class StatusController { + private readonly dayTranslations: Record = { + saturday: 'شنبه', + sunday: 'یکشنبه', + monday: 'دوشنبه', + tuesday: 'سه‌شنبه', + wednesday: 'چهارشنبه', + thursday: 'پنج‌شنبه', + friday: 'جمعه', + }; + + constructor(private readonly businessHoursService: BusinessHoursService) {} + + @Get('online-conversation') + @ApiOperation({ summary: 'Get online conversation availability status (public)' }) + @ApiResponse({ status: 200, description: 'Current availability status' }) + async getOnlineConversationStatus() { + try { + const status = await this.businessHoursService.isOpen(); + const nextOpen = await this.businessHoursService.getNextOpen(); + const config = await this.businessHoursService.getActiveConfig(); + + return new BaseResponseDTO(HttpStatus.OK, 'Status retrieved successfully', { + open: status.open, + nextOpen: nextOpen?.toISOString() || null, + timezone: config?.timezone || null, + currentWindow: status.currentWindow || null, + message: status.reason || null, + }); + } catch (error) { + throw new HttpException( + error.message || 'Failed to get status', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Get('working-hours') + @ApiOperation({ summary: 'Get business working hours schedule (public)' }) + @ApiResponse({ status: 200, description: 'Business hours schedule with Persian translations' }) + async getWorkingHours() { + try { + const config = await this.businessHoursService.getActiveConfig(); + + if (!config) { + return new BaseResponseDTO(HttpStatus.OK, 'No business hours configuration found', { + timezone: null, + weeklyWindows: [], + }); + } + + // Transform weeklyWindows to the requested format + // Sort days in order: saturday, sunday, monday, tuesday, wednesday, thursday, friday + const dayOrder = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + const sortedWindows = [...config.weeklyWindows].sort((a, b) => { + return dayOrder.indexOf(a.day) - dayOrder.indexOf(b.day); + }); + + const weeklyWindows: WeeklyWindowResponse[] = sortedWindows.map((window) => { + // Get the first interval (or use null if no intervals - means closed) + const firstInterval = window.intervals.length > 0 ? window.intervals[0] : null; + + return { + day: window.day, + dayTranslation: this.dayTranslations[window.day] || window.day, + from: firstInterval ? firstInterval.start : null, + to: firstInterval ? firstInterval.end : null, + }; + }); + + return new BaseResponseDTO(HttpStatus.OK, 'Business hours retrieved successfully', { + timezone: config.timezone, + weeklyWindows, + }); + } catch (error) { + throw new HttpException( + error.message || 'Failed to get working hours', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} + diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts new file mode 100644 index 0000000..c763837 --- /dev/null +++ b/src/categories/categories.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { CategoriesService } from './categories.service'; +import { CreateCategoryDto } from './dto/create-category.dto'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.CategoriesManage) +@ApiTags('categories module') +@Controller('categories') +export class CategoriesController { + constructor(private readonly categoriesService: CategoriesService) {} + + @Post() + create(@Body() createCategoryDto: CreateCategoryDto) { + return this.categoriesService.create(createCategoryDto); + } + + @Get() + findAll() { + return this.categoriesService.findAll(); + } +} diff --git a/src/categories/categories.module.ts b/src/categories/categories.module.ts new file mode 100644 index 0000000..b197e80 --- /dev/null +++ b/src/categories/categories.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { CategoriesController } from './categories.controller'; +import { CategoriesService } from './categories.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [CategoriesController], + providers: [CategoriesService], +}) +export class CategoriesModule {} diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts new file mode 100644 index 0000000..38d5777 --- /dev/null +++ b/src/categories/categories.service.ts @@ -0,0 +1,43 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { CategoriesModel } from 'src/database/model/categories.model'; +import { CreateCategoryDto } from './dto/create-category.dto'; + +@Injectable() +export class CategoriesService { + constructor( + @InjectModel(CategoriesModel.name) + private readonly categoriesModel: Model, + ) {} + async create(createCategoryDto: CreateCategoryDto) { + try { + const category = await this.categoriesModel.findOne({ + title: createCategoryDto.title, + }); + if (category) + throw new HttpException('category_exists', HttpStatus.BAD_REQUEST); + + const newCategory = await this.categoriesModel.create({ + title: createCategoryDto.title, + enTitle: createCategoryDto.enTitle, + icon: createCategoryDto.icon, + }); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', newCategory); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async findAll() { + try { + const categories = await this.categoriesModel.find(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', categories); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } +} diff --git a/src/categories/dto/create-category.dto.ts b/src/categories/dto/create-category.dto.ts new file mode 100644 index 0000000..48d2c16 --- /dev/null +++ b/src/categories/dto/create-category.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateCategoryDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'category of the uploaded dictionary', + example: 'carInsurance', + }) + title: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'category of the uploaded dictionary', + example: 'enTitle', + }) + enTitle: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'name of the icon picture name', + example: 'carInsurance', + }) + icon: string; +} diff --git a/src/categories/dto/update-category.dto.ts b/src/categories/dto/update-category.dto.ts new file mode 100644 index 0000000..d713b9b --- /dev/null +++ b/src/categories/dto/update-category.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCategoryDto } from './create-category.dto'; + +export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {} diff --git a/src/chat-attachments/attachment-session-expert.guard.ts b/src/chat-attachments/attachment-session-expert.guard.ts new file mode 100644 index 0000000..e9c1a1a --- /dev/null +++ b/src/chat-attachments/attachment-session-expert.guard.ts @@ -0,0 +1,27 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ChatService } from 'src/socket/support-management/chat.service'; + +@Injectable() +export class AttachmentSessionExpertGuard implements CanActivate { + constructor(private readonly chatService: ChatService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const sessionId = req.params?.sessionId as string; + const admin = req.admin; + if (!admin?._id) { + throw new UnauthorizedException('expert_token_required'); + } + await this.chatService.assertParticipantCanUploadVoice( + sessionId, + String(admin._id), + 'Expert', + ); + return true; + } +} diff --git a/src/chat-attachments/attachment-session-user.guard.ts b/src/chat-attachments/attachment-session-user.guard.ts new file mode 100644 index 0000000..c402e40 --- /dev/null +++ b/src/chat-attachments/attachment-session-user.guard.ts @@ -0,0 +1,27 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ChatService } from 'src/socket/support-management/chat.service'; + +@Injectable() +export class AttachmentSessionUserGuard implements CanActivate { + constructor(private readonly chatService: ChatService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const sessionId = req.params?.sessionId as string; + const user = req.user; + if (!user?._id) { + throw new UnauthorizedException('user_token_required'); + } + await this.chatService.assertParticipantCanUploadVoice( + sessionId, + String(user._id), + 'User', + ); + return true; + } +} diff --git a/src/chat-attachments/chat-attachments.controller.ts b/src/chat-attachments/chat-attachments.controller.ts new file mode 100644 index 0000000..355898d --- /dev/null +++ b/src/chat-attachments/chat-attachments.controller.ts @@ -0,0 +1,202 @@ +import { + BadRequestException, + Controller, + Get, + Param, + Post, + Query, + Req, + UploadedFile, + UseGuards, + UseInterceptors, + StreamableFile, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { tmpdir } from 'node:os'; +import { extname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import type { Request } from 'express'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity, CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminDocument } from 'src/database/model/admin.model'; +import { MAX_CHAT_VOICE_BYTES } from 'src/storage/storage.constants'; +import { AttachmentSessionExpertGuard } from './attachment-session-expert.guard'; +import { AttachmentSessionUserGuard } from './attachment-session-user.guard'; +import { ChatAttachmentsService } from './chat-attachments.service'; + +const attachmentDiskStorage = diskStorage({ + destination: (_req, _file, cb) => cb(null, tmpdir()), + filename: (_req, file, cb) => { + const raw = extname(file.originalname || '').toLowerCase().slice(0, 24); + const safe = /^\.[a-z0-9.]{1,20}$/.test(raw) ? raw : ''; + cb(null, `chat-att-${randomUUID()}${safe}`); + }, +}); + +@ApiTags('chat-attachments') +@Controller('chat/attachments') +@ApiBearerAuth() +export class ChatAttachmentsController { + constructor(private readonly attachments: ChatAttachmentsService) {} + + private parseDurationSec(raw: unknown): number | undefined { + if (raw === undefined || raw === null || raw === '') return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + } + + @Post('user/:sessionId') + @ApiOperation({ + summary: + 'Upload chat attachment (voice FFmpeg→m4a, no image, no PDF). Then sendMessage with message=storageKey, presetMessageId, type matching fileType.', + }) + @ApiParam({ name: 'sessionId' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'fileType'], + properties: { + file: { type: 'string', format: 'binary' }, + fileType: { + type: 'string', + enum: ['voice'], //? 'image', 'document' + }, + durationSec: { type: 'number', description: 'Voice: optional duration hint' }, + }, + }, + }) + @UseGuards(AttachmentSessionUserGuard) + @UseInterceptors( + FileInterceptor('file', { + storage: attachmentDiskStorage, + limits: { fileSize: MAX_CHAT_VOICE_BYTES }, + }), + ) + uploadUser( + @Param('sessionId') sessionId: string, + @UploadedFile() file: Express.Multer.File, + @Req() req: Request, + @CurrentIdentity() user: { _id?: unknown }, + ) { + if (!file) throw new BadRequestException('file_required'); + if (!user?._id) throw new BadRequestException('user_token_required'); + const fileType = this.attachments.parseFileType( + (req.body as Record)?.fileType, + ); + return this.attachments.uploadAttachment({ + sessionId, + file, + uploaderId: String(user._id), + uploaderRole: 'User', + fileType, + durationSec: this.parseDurationSec( + (req.body as Record)?.durationSec, + ), + }); + } + + @Post('expert/:sessionId') + @ApiOperation({ summary: 'Upload chat attachment (expert JWT)' }) + @ApiParam({ name: 'sessionId' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'fileType'], + properties: { + file: { type: 'string', format: 'binary' }, + fileType: { type: 'string', enum: ['voice', 'image', 'document'] }, + durationSec: { type: 'number' }, + }, + }, + }) + @UseGuards(AdminGuard, AttachmentSessionExpertGuard) + @Permissions(Permission.AttachmentsExpert) + @UseInterceptors( + FileInterceptor('file', { + storage: attachmentDiskStorage, + limits: { fileSize: MAX_CHAT_VOICE_BYTES }, + }), + ) + uploadExpert( + @Param('sessionId') sessionId: string, + @UploadedFile() file: Express.Multer.File, + @Req() req: Request, + @AdminIdentity() admin: AdminDocument, + ) { + if (!file) throw new BadRequestException('file_required'); + const fileType = this.attachments.parseFileType( + (req.body as Record)?.fileType, + ); + return this.attachments.uploadAttachment({ + sessionId, + file, + uploaderId: String(admin._id), + uploaderRole: 'Expert', + fileType, + durationSec: this.parseDurationSec( + (req.body as Record)?.durationSec, + ), + }); + } + + @Get('user/:sessionId/stream') + @ApiOperation({ + summary: 'Stream a chat attachment (private object). Query: key = full storageKey.', + }) + @ApiParam({ name: 'sessionId' }) + @ApiQuery({ + name: 'key', + required: true, + description: 'Full object key (same as message text for attachment messages)', + example: 'chats/.../voice/....m4a', + }) + @UseGuards(AttachmentSessionUserGuard) + async streamUser( + @Param('sessionId') sessionId: string, + @Query('key') storageKey: string, + ): Promise { + const { stream, contentType } = + await this.attachments.streamPrivateAttachment({ + sessionId, + storageKey, + }); + return new StreamableFile(stream, { + type: contentType, + disposition: `inline; filename="attachment"`, + }); + } + + @Get('expert/:sessionId/stream') + @ApiOperation({ summary: 'Stream attachment (expert JWT)' }) + @ApiParam({ name: 'sessionId' }) + @ApiQuery({ name: 'key', required: true }) + @UseGuards(AdminGuard, AttachmentSessionExpertGuard) + async streamExpert( + @Param('sessionId') sessionId: string, + @Query('key') storageKey: string, + ): Promise { + const { stream, contentType } = + await this.attachments.streamPrivateAttachment({ + sessionId, + storageKey, + }); + return new StreamableFile(stream, { + type: contentType, + disposition: `inline; filename="attachment"`, + }); + } +} diff --git a/src/chat-attachments/chat-attachments.module.ts b/src/chat-attachments/chat-attachments.module.ts new file mode 100644 index 0000000..5581588 --- /dev/null +++ b/src/chat-attachments/chat-attachments.module.ts @@ -0,0 +1,27 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { AudioModule } from 'src/audio/audio.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { DatabaseModule } from 'src/database/database.module'; +import { SupportManagementModule } from 'src/socket/support-management/support-management.module'; +import { AttachmentSessionExpertGuard } from './attachment-session-expert.guard'; +import { AttachmentSessionUserGuard } from './attachment-session-user.guard'; +import { ChatAttachmentsController } from './chat-attachments.controller'; +import { ChatAttachmentsService } from './chat-attachments.service'; + +@Module({ + imports: [ + DatabaseModule, + AuthModule, + AudioModule, + forwardRef(() => SupportManagementModule), + ], + controllers: [ChatAttachmentsController], + providers: [ + ChatAttachmentsService, + AttachmentSessionUserGuard, + AttachmentSessionExpertGuard, + AdminGuard, + ], +}) +export class ChatAttachmentsModule {} diff --git a/src/chat-attachments/chat-attachments.service.ts b/src/chat-attachments/chat-attachments.service.ts new file mode 100644 index 0000000..d682b7a --- /dev/null +++ b/src/chat-attachments/chat-attachments.service.ts @@ -0,0 +1,330 @@ +import { + BadRequestException, + ForbiddenException, + HttpStatus, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { createReadStream } from 'node:fs'; +import { stat, unlink } from 'node:fs/promises'; +import type { Readable } from 'node:stream'; +import { Model, Types } from 'mongoose'; +import { AudioNormalizationError } from 'src/audio/audio-normalization.errors'; +import { AudioNormalizationService } from 'src/audio/audio-normalization.service'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { ChatMessageAttachmentModel } from 'src/database/model/chat-message-attachment.model'; +import { + NORMALIZED_VOICE_EXT, + NORMALIZED_VOICE_MIME, + allowedMimeForChatFileType, + isVoiceUploadMimeAccepted, + maxBytesForChatFileType, +} from 'src/storage/storage.constants'; +import type { ChatAttachmentFileType } from 'src/storage/storage.types'; +import { + buildChatAttachmentStorageKey, + extensionFromMime, + normalizeExt, +} from 'src/storage/key-builder.helper'; +import { StorageService } from 'src/storage/storage.service'; + +const VOICE_TYPES = new Set(['voice']); +const NON_VOICE_TYPES = new Set(['image', 'document']); + +@Injectable() +export class ChatAttachmentsService { + constructor( + private readonly storage: StorageService, + private readonly audioNormalizer: AudioNormalizationService, + @InjectModel(ChatMessageAttachmentModel.name) + private readonly attachments: Model, + ) {} + + parseFileType(raw: unknown): ChatAttachmentFileType { + const s = String(raw ?? '') + .trim() + .toLowerCase(); + if (s === 'voice' || s === 'image' || s === 'document') return s; + throw new BadRequestException('invalid_fileType_expected_voice_image_or_document'); + } + + async uploadAttachment(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + fileType: ChatAttachmentFileType; + durationSec?: number; + }): Promise { + const { fileType } = params; + if (VOICE_TYPES.has(fileType)) { + return this.handleVoiceUpload(params); + } + if (NON_VOICE_TYPES.has(fileType)) { + return this.handleBinaryAttachmentUpload(params); + } + throw new BadRequestException('invalid_fileType'); + } + + private async handleVoiceUpload(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + durationSec?: number; + }): Promise { + const { sessionId, uploaderId, uploaderRole, file, durationSec } = params; + const inputPath = file.path; + if (!inputPath) { + throw new BadRequestException('attachment_requires_disk_upload'); + } + if (!isVoiceUploadMimeAccepted(file.mimetype)) { + throw new BadRequestException('invalid_audio_mime'); + } + + const maxB = maxBytesForChatFileType('voice'); + if (file.size > maxB) { + await unlink(inputPath).catch(() => {}); + throw new BadRequestException('voice_file_too_large'); + } + + const messageId = new Types.ObjectId(); + const ext = normalizeExt(NORMALIZED_VOICE_EXT); + const storageKey = buildChatAttachmentStorageKey( + sessionId, + messageId.toHexString(), + 'voice', + ext, + ); + + let normalizedPath: string | null = null; + try { + const { outputAbsolutePath, durationSec: probedDuration } = + await this.audioNormalizer.transcodeIncomingVoiceToM4a(inputPath); + normalizedPath = outputAbsolutePath; + + const st = await stat(outputAbsolutePath); + if (!st.size) { + throw new BadRequestException('normalized_voice_empty'); + } + + const stream = createReadStream(outputAbsolutePath); + try { + await this.storage.putPrivateObject({ + key: storageKey, + body: stream, + contentType: NORMALIZED_VOICE_MIME, + contentLength: st.size, + metadata: { + sessionid: sessionId, + messageid: messageId.toHexString(), + normalized: 'aac_mp4', + }, + }); + } finally { + stream.destroy(); + } + + const finalDuration = + probedDuration != null && Number.isFinite(probedDuration) + ? probedDuration + : durationSec != null && !Number.isNaN(Number(durationSec)) + ? Number(durationSec) + : undefined; + + await this.attachments.create({ + messageId, + sessionId: new Types.ObjectId(sessionId), + uploaderId, + uploaderRole, + fileType: 'voice', + storageKey, + bucket: 'private', + originalFilename: file.originalname || `voice${NORMALIZED_VOICE_EXT}`, + mimeType: NORMALIZED_VOICE_MIME, + size: st.size, + ...(finalDuration != null ? { durationSec: finalDuration } : {}), + status: 'ready', + }); + + const presetMessageId = messageId.toHexString(); + return new BaseResponseDTO(HttpStatus.OK, 'voice_uploaded', { + fileType: 'voice' as const, + storageKey, + messageId: presetMessageId, + presetMessageId, + durationSec: finalDuration ?? null, + mimeType: NORMALIZED_VOICE_MIME, + size: st.size, + normalized: true, + originalMimeType: (file.mimetype || '').trim().toLowerCase() || null, + }); + } catch (e: unknown) { + if (e instanceof BadRequestException) throw e; + if (e instanceof AudioNormalizationError) { + const msg = e.message; + if (msg.includes('binary_not_found')) { + throw new ServiceUnavailableException( + 'ffmpeg_not_available_voice_processing_unavailable', + ); + } + this.mapTranscodeFailure(msg); + } + throw e; + } finally { + await unlink(inputPath).catch(() => {}); + if (normalizedPath) { + await unlink(normalizedPath).catch(() => {}); + } + } + } + + private mapTranscodeFailure(message: string): never { + if (message.includes('timeout')) { + throw new BadRequestException('voice_transcode_timeout'); + } + throw new BadRequestException('voice_transcode_failed'); + } + + private async handleImageOrDocumentUpload(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + fileType: 'image' | 'document'; + durationSec?: number; + }): Promise { + void params.durationSec; + const { sessionId, uploaderId, uploaderRole, file, fileType } = params; + const inputPath = file.path; + if (!inputPath) { + throw new BadRequestException('attachment_requires_disk_upload'); + } + + const mime = (file.mimetype || '').trim().toLowerCase(); + if (!allowedMimeForChatFileType(fileType, mime)) { + await unlink(inputPath).catch(() => {}); + throw new BadRequestException(`invalid_mime_for_${fileType}`); + } + + const maxB = maxBytesForChatFileType(fileType); + if (file.size > maxB) { + await unlink(inputPath).catch(() => {}); + throw new BadRequestException(`${fileType}_file_too_large`); + } + + const ext = extensionFromMime(mime); + const messageId = new Types.ObjectId(); + const storageKey = buildChatAttachmentStorageKey( + sessionId, + messageId.toHexString(), + fileType, + ext, + ); + + try { + const stream = createReadStream(inputPath); + try { + await this.storage.putPrivateObject({ + key: storageKey, + body: stream, + contentType: mime, + contentLength: file.size, + metadata: { + sessionid: sessionId, + messageid: messageId.toHexString(), + filetype: fileType, + }, + }); + } finally { + stream.destroy(); + } + + await this.attachments.create({ + messageId, + sessionId: new Types.ObjectId(sessionId), + uploaderId, + uploaderRole, + fileType, + storageKey, + bucket: 'private', + originalFilename: file.originalname || `${fileType}${ext}`, + mimeType: mime, + size: file.size, + status: 'ready', + }); + + const presetMessageId = messageId.toHexString(); + return new BaseResponseDTO(HttpStatus.OK, 'attachment_uploaded', { + fileType, + storageKey, + messageId: presetMessageId, + presetMessageId, + mimeType: mime, + size: file.size, + normalized: false, + }); + } finally { + await unlink(inputPath).catch(() => {}); + } + } + + private async handleBinaryAttachmentUpload(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + fileType: ChatAttachmentFileType; + durationSec?: number; + }): Promise { + const { fileType } = params; + if (fileType !== 'image' && fileType !== 'document') { + throw new BadRequestException('invalid_fileType'); + } + return this.handleImageOrDocumentUpload({ + ...params, + fileType, + }); + } + + /** + * Stream a private attachment after verifying it exists for this session. + */ + async streamPrivateAttachment(params: { + sessionId: string; + storageKey: string; + }): Promise<{ stream: Readable; contentType: string }> { + const { sessionId, storageKey } = params; + const key = (storageKey ?? '').trim(); + if (!key) { + throw new BadRequestException('key_query_required'); + } + const expectedPrefix = `chats/${sessionId}/`; + if (!key.startsWith(expectedPrefix)) { + throw new ForbiddenException('attachment_key_not_in_session'); + } + + const row = await this.attachments + .findOne({ + sessionId: new Types.ObjectId(sessionId), + storageKey: key, + }) + .lean() + .exec(); + + if (!row) { + throw new NotFoundException('attachment_not_found'); + } + + const stream = await this.storage.getObjectReadable({ + kind: 'private', + key, + }); + return { + stream, + contentType: row.mimeType || 'application/octet-stream', + }; + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..8ada9c5 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,127 @@ +import { writeFile } from 'node:fs/promises'; +import { Command } from 'commander'; +import inquirer from 'inquirer'; + +export class CliService { + private readonly program: Command; + constructor() { + this.program = new Command(); + this.setupCommands(); + } + + getServiceParameters() { + return; + } + + private setupCommands() { + this.program + .command('ask') + .description('Ask the user questions based on their role') + .action(async () => { + await this.askWorkerType(); + }); + + this.program.parse(process.argv); + } + private async askWorkerType() { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'worker', + message: 'What is your role? admin | expert', + }, + ]); + + if (answers.worker === 'admin') { + await this.askAdminQuestions(); + } else if (answers.worker === 'expert') { + await this.askExpertQuestions(); + } else { + console.log('Invalid input. Please enter "admin" or "expert".'); + await this.askWorkerType(); + } + } + + private async askExpertQuestions() { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'expertMobile', + message: 'What is your Mobile (Expert)?', + }, + { + type: 'input', + name: 'expertEmail', + message: 'What is your email (Expert)?', + validate: (input: string) => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (emailRegex.test(input)) { + return true; + } + return 'Please enter a valid email address'; + }, + }, + { + type: 'password', + name: 'expertPassword', + message: 'Enter a password (Expert):', + }, + ]); + + writeFile( + `${process.cwd()}/src/static/expert.txt`, + `${answers.expertEmail}-${answers.expertPassword}-${answers.expertMobile}`, + ); + } + + private async askAdminQuestions() { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'adminMobile', + message: 'What is your Mobile (Admin)?', + }, + { + type: 'input', + name: 'adminEmail', + message: 'What is your email (Admin)?', + validate: (input: string) => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (emailRegex.test(input)) { + return true; + } + return 'Please enter a valid email address'; + }, + }, + { + type: 'password', + name: 'adminPassword', + message: 'Enter a password (Admin):', + }, + ]); + + writeFile( + `${process.cwd()}/src/static/admin.txt`, + `${answers.adminEmail}-${answers.adminPassword}-${answers.adminMobile}`, + ); + } + + private async askClientQuestions() { + await inquirer.prompt([ + { + type: 'input', + name: 'clientName', + message: 'What is your name (Client)?', + }, + { + type: 'input', + name: 'clientEmail', + message: 'What is your email (Client)?', + }, + ]); + } +} + +(() => { + new CliService(); +})(); diff --git a/src/client-management/client-management.controller.ts b/src/client-management/client-management.controller.ts new file mode 100644 index 0000000..19b3bd1 --- /dev/null +++ b/src/client-management/client-management.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + } from '@nestjs/common'; + import { + ApiOperation, + ApiBody, + ApiDefaultResponse, + ApiBearerAuth, + ApiParam, + ApiTags, + ApiQuery, + } from '@nestjs/swagger'; + import { AdminGuard } from 'src/auth/guards/admin.guard'; + import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; + import { Permissions } from 'src/common/decorators/permission.decorator'; + import { Permission } from 'src/common/types/permissions.catalog'; + import { AdminModel } from 'src/database/model/admin.model'; + +import { ClientManagementService } from './client-management.service'; +import { CreateClientDto } from './dto/clients.dto'; + + @ApiBearerAuth() + @Permissions(Permission.ClientsManage) + @UseGuards(AdminGuard) + @ApiTags('Client-Management') + @Controller('client') + + export class ClientManagementController { + constructor(private readonly clientManagementService: ClientManagementService) {} + + @ApiBody({type: CreateClientDto }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Permissions(Permission.ClientsManage) + @Post() + createClient(@Body() createClientDto: CreateClientDto) { + return this.clientManagementService.createClient(createClientDto); + } + + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Permissions(Permission.ClientsManage) + @Get() + getClients() { + return this.clientManagementService.getClients(); + } + } + \ No newline at end of file diff --git a/src/client-management/client-management.module.ts b/src/client-management/client-management.module.ts new file mode 100644 index 0000000..374f52b --- /dev/null +++ b/src/client-management/client-management.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { ClientManagementService } from './client-management.service'; +import { ClientManagementController } from './client-management.controller'; + +@Module({ + imports: [DatabaseModule, DatabaseModule], + controllers: [ClientManagementController], + exports: [ClientManagementService], + providers: [ClientManagementService, DatabaseModule], +}) +export class ClientManagementModule {} diff --git a/src/client-management/client-management.service.ts b/src/client-management/client-management.service.ts new file mode 100644 index 0000000..ffc9478 --- /dev/null +++ b/src/client-management/client-management.service.ts @@ -0,0 +1,54 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { ClientModel } from 'src/database/model/client.model'; +import { Model } from 'mongoose'; +import { CreateClientDto } from './dto/clients.dto'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; + +@Injectable() +export class ClientManagementService { + constructor( + @InjectModel(ClientModel.name) private readonly client: Model, + ) {} + + async createClient(createClientDto:CreateClientDto){ +try { + const client = await this.client.findOne({enName: createClientDto.enName}); + if (client) throw new HttpException('client_exists', HttpStatus.NOT_FOUND); + + const { randomBytes } = await import('crypto'); + const apiKey = randomBytes(32).toString('hex'); + const newClient = await this.client.create({ + name: createClientDto.name, + enName: createClientDto.enName, + apiKey, + status: 'active', + }); + + return new BaseResponseDTO(HttpStatus.CREATED, 'Client created successfully', { + apiKey: newClient.apiKey, + name: newClient.name, + enName: newClient.enName, + }); +} catch (err) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Failed to create client', err); +} + } + + async getClients(){ + try { + const clients = await this.client.find({status: 'active'}); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', clients); + } catch (err) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Failed to create client', err); + } + } + + async findClientByApiKey(apiKey: string) { + try { + return await this.client.findOne({ apiKey }); + } catch (err) { + throw new HttpException('Failed to find client by API key', HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/client-management/dto/clients.dto.ts b/src/client-management/dto/clients.dto.ts new file mode 100644 index 0000000..79801a7 --- /dev/null +++ b/src/client-management/dto/clients.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class CreateClientDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'persian name of the client', + example: 'بیمه سامان', + }) + name: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'english name of the client', + example: 'Saman-Insurance', + }) + enName: string; + } \ No newline at end of file diff --git a/src/common/decorators/Identity.decorator.ts b/src/common/decorators/Identity.decorator.ts new file mode 100644 index 0000000..c2c5757 --- /dev/null +++ b/src/common/decorators/Identity.decorator.ts @@ -0,0 +1,20 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentIdentity = createParamDecorator( + (data: keyof any, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + + // If a specific property of the user is requested (e.g., user ID or role) + // Return just that property; otherwise, return the full user object + return data ? user?.[data] : user; + }, +); +export const AdminIdentity = createParamDecorator( + (data: keyof any, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request['admin']; + + return data ? user?.[data] : user; + }, +); diff --git a/src/common/decorators/permission.decorator.ts b/src/common/decorators/permission.decorator.ts new file mode 100644 index 0000000..ff8a0fe --- /dev/null +++ b/src/common/decorators/permission.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; +import { Permission } from 'src/common/types/permissions.catalog'; + +export const PERMISSIONS_KEY = 'permissions'; + +/** Require ANY of the listed permissions (OR). Owner always passes. */ +export const Permissions = (...permissions: Permission[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/src/common/decorators/role.decorator.ts b/src/common/decorators/role.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/src/common/decorators/role.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/decorators/skip-admin-rate-limit.decorator.ts b/src/common/decorators/skip-admin-rate-limit.decorator.ts new file mode 100644 index 0000000..3e6118f --- /dev/null +++ b/src/common/decorators/skip-admin-rate-limit.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_ADMIN_RATE_LIMIT_KEY = 'skipAdminRateLimit'; +export const SkipAdminRateLimit = () => SetMetadata(SKIP_ADMIN_RATE_LIMIT_KEY, true); \ No newline at end of file diff --git a/src/common/dto/base-response.dto.ts b/src/common/dto/base-response.dto.ts new file mode 100644 index 0000000..4e68c4d --- /dev/null +++ b/src/common/dto/base-response.dto.ts @@ -0,0 +1,77 @@ +import { HttpStatus } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class PageOptionsDto { + @ApiPropertyOptional({ + minimum: 1, + default: 1, + }) + @Type(() => Number) + @IsInt() + @Min(1) + @IsOptional() + readonly page?: number = 1; + + @ApiPropertyOptional({ + minimum: 1, + // maximum: 50, + default: 10, + }) + @Type(() => Number) + @IsInt() + @Min(1) + // @Max(50) + @IsOptional() + readonly take?: number = 10; + + get skip(): number { + return (this.page - 1) * this.take; + } +} +export interface PageMetaDtoParameters { + pageOptionsDto: PageOptionsDto; + itemCount: number; +} +export class PageMetaDto { + @ApiProperty() + readonly page: number; + + @ApiProperty() + readonly take: number; + + @ApiProperty() + readonly itemCount: number; + + @ApiProperty() + readonly pageCount: number; + + constructor({ pageOptionsDto, itemCount }: PageMetaDtoParameters) { + this.page = pageOptionsDto.page; + this.take = pageOptionsDto.take; + this.itemCount = itemCount; + this.pageCount = Math.ceil(this.itemCount / this.take); + } +} + +export class BaseResponseDTO { + statusCode: HttpStatus; + message: string; + data: any; + @ApiProperty({ type: () => PageMetaDto }) + readonly meta: PageMetaDto; + + constructor(statusCode: HttpStatus, message, data: any, meta?: PageMetaDto) { + this.statusCode = statusCode; + this.message = message; + this.data = data; + this.meta = meta; + } + mapDataIfArray(mapper: (item: any) => T): T[] { + if (Array.isArray(this.data)) { + return this.data.map(mapper); + } + return { ...this.data }; + } +} diff --git a/src/common/dto/forgetPassword.dto.ts b/src/common/dto/forgetPassword.dto.ts new file mode 100644 index 0000000..0be1683 --- /dev/null +++ b/src/common/dto/forgetPassword.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail } from 'class-validator'; + +export class ForgetPasswordDTO { + @IsEmail() + @ApiProperty() + username: string; +} diff --git a/src/common/dto/login.dto.ts b/src/common/dto/login.dto.ts new file mode 100644 index 0000000..52be13a --- /dev/null +++ b/src/common/dto/login.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, ValidateIf } from 'class-validator'; + +export class UserLoginDTO { + @ApiProperty({ + required: true, + type: 'string', + description: 'mobile of user', + example: '09226187419', + }) + mobile: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'nationalCode of user', + example: '4311402422', + }) + @ValidateIf((o) => o.twoFactor === true) + nationalCode: string | null; + + @ApiProperty({ + required: true, + type: 'string', + default: false, + description: + 'if true , both nationalCode and mobile and if false , just mobile should be sent.', + example: false, + }) + twoFactor: boolean; +} + +export class UserVerifyOtpDTO { + @ApiProperty({ + required: true, + type: 'string', + description: 'mobile of user', + example: '09226187419', + }) + mobile: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'otp send to the mobile of user', + example: '27246', + }) + otp: string; +} + +export class AdminLoginDTO { + @ApiProperty({ + required: true, + type: 'string', + description: 'username of admin', + example: 'admin@chatbot.com', + }) + @IsEmail() + username: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'password of admin', + example: 'p@$$word123321', + }) + password: string; + // @ApiProperty({ examples: Role, required: true, enum: Role }) + // role: Role; +} diff --git a/src/common/dto/resetPassword.dto.ts b/src/common/dto/resetPassword.dto.ts new file mode 100644 index 0000000..6275b5c --- /dev/null +++ b/src/common/dto/resetPassword.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsStrongPassword } from 'class-validator'; + +export class ResetPasswordDTO { + @ApiProperty() + @IsString() + resetToken: string; + + @ApiProperty() + @IsStrongPassword() + newPassword: string; +} diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..0012efa --- /dev/null +++ b/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,30 @@ +import { Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core'; +import { Request, Response } from 'express'; +import { INestApplication } from '@nestjs/common'; // Keep this import for app.close() + +@Catch() +export class AllExceptionsFilter extends BaseExceptionFilter { + private readonly nestApplication: INestApplication; // Rename to avoid conflict with super constructor + + constructor(httpAdapterHost: HttpAdapterHost, nestApplication: INestApplication) { + super(httpAdapterHost.httpAdapter); + this.nestApplication = nestApplication; + } + + async catch(exception: T, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + + if (exception instanceof HttpException || (exception && typeof exception === 'object' && 'statusCode' in exception && 'message' in exception)) { + super.catch(exception, host); + } else { + const status = HttpStatus.INTERNAL_SERVER_ERROR; + console.error(`CRITICAL ERROR - HTTP Status: ${status} - Path: ${request.url}`); + console.error('Caught by AllExceptionsFilter (forcing exit):', exception); + + await this.nestApplication.close(); + process.exit(1); + } + } +} diff --git a/src/common/filters/ws-all-exceptions.filter.ts b/src/common/filters/ws-all-exceptions.filter.ts new file mode 100644 index 0000000..8b8f782 --- /dev/null +++ b/src/common/filters/ws-all-exceptions.filter.ts @@ -0,0 +1,54 @@ +import { ArgumentsHost, Catch, HttpException, HttpStatus } from '@nestjs/common'; +import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; + +@Catch() +export class WsAllExceptionsFilter extends BaseWsExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const client: any = host.switchToWs().getClient(); + + let message = 'Unknown error'; + let status = HttpStatus.BAD_REQUEST; + let details: any = undefined; + + if (exception instanceof HttpException) { + status = exception.getStatus?.() ?? HttpStatus.BAD_REQUEST; + const res: any = exception.getResponse?.(); + if (typeof res === 'string') message = res; + else if (res && typeof res === 'object') { + message = res.message || exception.message; + details = res; + } else { + message = exception.message; + } + } else if (exception instanceof WsException) { + const err = exception.getError(); + if (typeof err === 'string') message = err; + else if (err && typeof err === 'object') { + message = (err as any).message || 'Websocket error'; + details = err; + } else { + message = exception.message; + } + } else if (exception && typeof exception === 'object') { + message = (exception as any).message || message; + } + + try { + client.emit('response', { + event: 'error', + statusCode: status, + message, + data: {}, + meta: details, + }); + } catch { + // ignore emit errors + } + + super.catch(exception, host); + } +} + + + + diff --git a/src/common/helpers/redis.service.ts b/src/common/helpers/redis.service.ts new file mode 100644 index 0000000..3ab9b24 --- /dev/null +++ b/src/common/helpers/redis.service.ts @@ -0,0 +1,66 @@ +// redis.service.ts +import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Redis } from 'ioredis'; + +@Injectable() +export class RedisService { + constructor(@InjectRedis() private readonly redis: Redis) {} + + //* Returns the raw ioredis client + getClient(): Redis { + return this.redis; + } + + //* Sets a key-value pair with optional expiration (in seconds) + async set(key: string, value: any, ttlSeconds?: number): Promise { + const stringValue = JSON.stringify(value); + if (ttlSeconds) { + await this.redis.set(key, stringValue, 'EX', ttlSeconds); + } else { + await this.redis.set(key, stringValue); + } + } + + //* Gets a parsed JSON value from Redis + async get(key: string): Promise { + const result = await this.redis.get(key); + return result ? JSON.parse(result) : null; + } + + //* Deletes a key + async del(key: string): Promise { + await this.redis.del(key); + } + + //* Push a value to a list (for queue operations) + async enqueue(queueName: string, value: any): Promise { + await this.redis.rpush(queueName, JSON.stringify(value)); + } + + //* Pops a value from the left of a list (FIFO queue) + async dequeue(queueName: string): Promise { + const result = await this.redis.lpop(queueName); + return result ? JSON.parse(result) : null; + } + + //* Returns the queue length + async queueLength(queueName: string): Promise { + return await this.redis.llen(queueName); + } + + // * Gets all values from a queue + async getAllFromQueue(queueName: string): Promise { + const items = await this.redis.lrange(queueName, 0, -1); + return items.map((x) => JSON.parse(x)); + } + async blacklistToken(token: string, ttlInSeconds: number): Promise { + const ttlInMs = ttlInSeconds * 1000; + await this.redis.set(`blacklist:${token}`, 'revoked', 'PX', ttlInMs); + } + + async isTokenBlacklisted(token: string): Promise { + const result = await this.redis.get(`blacklist:${token}`); + return result === 'revoked'; + } +} diff --git a/src/common/helpers/structured-logger.service.ts b/src/common/helpers/structured-logger.service.ts new file mode 100644 index 0000000..8ad1005 --- /dev/null +++ b/src/common/helpers/structured-logger.service.ts @@ -0,0 +1,204 @@ +import { Logger, LoggerService } from '@nestjs/common'; + +/** + * Log categories for better organization + */ +export enum LogCategory { + // Connection lifecycle + CONNECTION = 'CONNECTION', + DISCONNECT = 'DISCONNECT', + + // Room management + ROOM = 'ROOM', + JOIN = 'JOIN', + LEAVE = 'LEAVE', + + // Expert management + EXPERT = 'EXPERT', + EXPERT_ONLINE = 'EXPERT_ONLINE', + EXPERT_OFFLINE = 'EXPERT_OFFLINE', + EXPERT_REASSIGN = 'EXPERT_REASSIGN', + + // User management + USER = 'USER', + USER_QUEUE = 'USER_QUEUE', + + // Messaging + MESSAGE = 'MESSAGE', + MESSAGE_SEND = 'MESSAGE_SEND', + MESSAGE_EDIT = 'MESSAGE_EDIT', + MESSAGE_SEEN = 'MESSAGE_SEEN', + + // Chat lifecycle + CHAT = 'CHAT', + CHAT_START = 'CHAT_START', + CHAT_END = 'CHAT_END', + CHAT_CLOSE = 'CHAT_CLOSE', + CHAT_AUTO_CLOSE = 'CHAT_AUTO_CLOSE', + + // Redis operations + REDIS = 'REDIS', + REDIS_SET = 'REDIS_SET', + REDIS_HASH = 'REDIS_HASH', + REDIS_QUEUE = 'REDIS_QUEUE', + REDIS_REPAIR = 'REDIS_REPAIR', + + // Validation + VALIDATION = 'VALIDATION', + + // Background tasks + BACKGROUND = 'BACKGROUND', + WATCHER = 'WATCHER', + SCHEDULER = 'SCHEDULER', + + // Errors + ERROR = 'ERROR', + + // General + INFO = 'INFO', + DEBUG = 'DEBUG', +} + +/** + * Structured Logger Service + * Provides organized logging with categories and context + */ +export class StructuredLogger implements LoggerService { + private readonly logger: Logger; + private readonly context: string; + + constructor(context: string) { + this.context = context; + this.logger = new Logger(context); + } + + /** + * Format log message with category and context + */ + private formatMessage(category: LogCategory, message: string, context?: Record): string { + const categoryTag = `[${category}]`; + const contextStr = context ? ` ${JSON.stringify(context)}` : ''; + return `${categoryTag} ${message}${contextStr}`; + } + + /** + * Log info message + */ + log(category: LogCategory, message: string, context?: Record): void; + log(message: string, context?: Record): void; + log(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format: just message + this.logger.log(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format: category + message + context + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + const ctx = context || {}; + this.logger.log(this.formatMessage(category, message, ctx)); + } + } + + /** + * Log warning message + */ + warn(category: LogCategory, message: string, context?: Record, error?: any): void; + warn(message: string, context?: Record): void; + warn(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, contextOrError?: Record | any, error?: any): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + this.logger.warn(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + // Check if contextOrError is an error object or context + let ctx: Record = {}; + let err: any = undefined; + if (contextOrError) { + if (contextOrError instanceof Error || (typeof contextOrError === 'object' && contextOrError !== null && 'stack' in contextOrError)) { + err = contextOrError; + } else { + ctx = contextOrError as Record; + } + } + if (error) { + err = error; + } + const formattedMessage = this.formatMessage(category, message, ctx); + if (err) { + this.logger.warn(formattedMessage, err); + } else { + this.logger.warn(formattedMessage); + } + } + } + + /** + * Log error message + */ + error(category: LogCategory, message: string, error?: any, context?: Record): void; + error(message: string, error?: any, context?: Record): void; + error(categoryOrMessage: LogCategory | string, messageOrError?: string | any, errorOrContext?: any, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + const message = categoryOrMessage; + const error = messageOrError; + const ctx = errorOrContext; + if (error) { + this.logger.error(`${message} ${ctx ? JSON.stringify(ctx) : ''}`, error); + } else { + this.logger.error(message, ctx); + } + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrError as string; + const error = errorOrContext; + const ctx = context || {}; + const formattedMessage = this.formatMessage(category, message, ctx); + if (error) { + this.logger.error(formattedMessage, error); + } else { + this.logger.error(formattedMessage); + } + } + } + + /** + * Log debug message + */ + debug(category: LogCategory, message: string, context?: Record): void; + debug(message: string, context?: Record): void; + debug(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + this.logger.debug(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + const ctx = context || {}; + this.logger.debug(this.formatMessage(category, message, ctx)); + } + } + + /** + * Log verbose message + */ + verbose(category: LogCategory, message: string, context?: Record): void; + verbose(message: string, context?: Record): void; + verbose(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + this.logger.verbose(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + const ctx = context || {}; + this.logger.verbose(this.formatMessage(category, message, ctx)); + } + } +} + diff --git a/src/common/helpers/versioning.helper.ts b/src/common/helpers/versioning.helper.ts new file mode 100644 index 0000000..13e92e8 --- /dev/null +++ b/src/common/helpers/versioning.helper.ts @@ -0,0 +1,46 @@ +import { AiServiceModule } from 'src/ai-service/ai-service.module'; +import { AiV2Module } from 'src/ai-v2/ai-v2.module'; +import { AdminModule } from 'src/api/admin/admin.module'; +import { UserModule } from 'src/api/user/user.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { AclModule } from 'src/acl/acl.module'; +import { CategoriesModule } from 'src/categories/categories.module'; +import { ClientManagementModule } from 'src/client-management/client-management.module'; +import { ConversationModule } from 'src/conversation/conversation.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { StorageModule } from 'src/storage/storage.module'; +import { DictionariesModule } from 'src/dictionaries/dictionaries.module'; +import { InquiriesModule } from 'src/externals/inquiries/inquiries.module'; +import { SmsModule } from 'src/externals/sms/sms.module'; +import { SsoModule } from 'src/externals/sso/sso.module'; +import { ChatAttachmentsModule } from 'src/chat-attachments/chat-attachments.module'; +import { SupportManagementModule } from 'src/socket/support-management/support-management.module'; +import { UploadModule } from 'src/upload/upload.module'; + +export function getAppImports(): any[] { + const modules = [ + DatabaseModule, + StorageModule, + AuthModule, + AclModule, + AiServiceModule, + AiV2Module, + SmsModule, + SsoModule, + InquiriesModule, + UserModule, + AdminModule, + ClientManagementModule, + UploadModule, + DictionariesModule, + CategoriesModule, + ]; + + if (process.env.SUPPORT_MANAGEMENT === 'true') { + modules.push(SupportManagementModule); + modules.push(ConversationModule); + modules.push(ChatAttachmentsModule); + } + + return modules; +} diff --git a/src/common/middlewares/security-headers.middleware.ts b/src/common/middlewares/security-headers.middleware.ts new file mode 100644 index 0000000..0a00479 --- /dev/null +++ b/src/common/middlewares/security-headers.middleware.ts @@ -0,0 +1,18 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class SecurityHeadersMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + // Clickjacking protection + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + + // Other recommended security headers + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + + next(); + } +} diff --git a/src/common/middlewares/swagger-auth.middleware.ts b/src/common/middlewares/swagger-auth.middleware.ts new file mode 100644 index 0000000..a601225 --- /dev/null +++ b/src/common/middlewares/swagger-auth.middleware.ts @@ -0,0 +1,53 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class SwaggerAuthMiddleware implements NestMiddleware { + constructor(private readonly configService: ConfigService) {} + + use(req: Request, res: Response, next: NextFunction) { + // Only protect Swagger endpoints + if (req.path.startsWith('/docs')) { + const swaggerUsername = this.configService.get('SWAGGER_USERNAME'); + const swaggerPassword = this.configService.get('SWAGGER_PASSWORD'); + + // If no credentials are set, allow access (optional protection) + if (!swaggerUsername && !swaggerPassword) { + return next(); + } + + // If only one is set, require both + if (!swaggerUsername || !swaggerPassword) { + return this.sendAuthRequired(res); + } + + // Extract Basic Auth credentials + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Basic ')) { + return this.sendAuthRequired(res); + } + + // Decode Basic Auth + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + const [username, password] = credentials.split(':'); + + // Validate both username and password + if (username === swaggerUsername && password === swaggerPassword) { + return next(); + } + + return this.sendAuthRequired(res); + } + + next(); + } + + private sendAuthRequired(res: Response) { + res.setHeader('WWW-Authenticate', 'Basic realm="Swagger API Documentation"'); + res.status(401).send('Authentication required to access Swagger documentation'); + } +} + diff --git a/src/common/services/audit-log.service.ts b/src/common/services/audit-log.service.ts new file mode 100644 index 0000000..e021190 --- /dev/null +++ b/src/common/services/audit-log.service.ts @@ -0,0 +1,201 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { AuditLogModel } from 'src/database/model/audit-log.model'; +import { Socket } from 'socket.io'; + +export interface AuditLogData { + userId?: string; + action: string; + resource?: string; + resourceId?: string; + oldValues?: Record; + newValues?: Record; + changes?: Record; + ipAddress?: string; + userAgent?: string; + method?: string; + endpoint?: string; + statusCode?: number; + metadata?: Record; +} + +@Injectable() +export class AuditLogService { + constructor( + @InjectModel(AuditLogModel.name) + private readonly auditLogModel: Model, + ) {} + + /** + * Extract IP address and user agent from Socket.io client + */ + private extractClientInfo(client: Socket): { ipAddress?: string; userAgent?: string } { + const ipAddress = + (client.handshake.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (client.handshake.headers['x-real-ip'] as string) || + client.handshake.address || + client.conn?.remoteAddress || + undefined; + + const userAgent = + (client.handshake.headers['user-agent'] as string) || undefined; + + return { ipAddress, userAgent }; + } + + /** + * Calculate changes between old and new values + */ + private calculateChanges( + oldValues?: Record, + newValues?: Record, + ): Record | undefined { + if (!oldValues || !newValues) { + return undefined; + } + + const changes: Record = {}; + const allKeys = new Set([...Object.keys(oldValues), ...Object.keys(newValues)]); + + for (const key of allKeys) { + const oldVal = oldValues[key]; + const newVal = newValues[key]; + + // Only include if values actually changed + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes[key] = { from: oldVal, to: newVal }; + } + } + + return Object.keys(changes).length > 0 ? changes : undefined; + } + + /** + * Log an audit event + */ + async log(data: AuditLogData, client?: Socket): Promise { + try { + // Extract client info if socket is provided + const clientInfo = client ? this.extractClientInfo(client) : {}; + + // Calculate changes if both old and new values are provided + const changes = data.changes || this.calculateChanges(data.oldValues, data.newValues); + + const auditLog = new this.auditLogModel({ + userId: data.userId ? (data.userId as any) : undefined, + action: data.action, + resource: data.resource, + resourceId: data.resourceId, + oldValues: data.oldValues, + newValues: data.newValues, + changes, + ipAddress: data.ipAddress || clientInfo.ipAddress, + userAgent: data.userAgent || clientInfo.userAgent, + method: data.method, + endpoint: data.endpoint, + statusCode: data.statusCode, + metadata: data.metadata, + createdAt: new Date(), + }); + + await auditLog.save(); + } catch (error) { + // Don't throw errors - audit logging should not break the main flow + console.error('Failed to save audit log:', error); + } + } + + /** + * Log a WebSocket event + */ + async logWebSocketEvent( + action: string, + client: Socket, + options: { + userId?: string; + resource?: string; + resourceId?: string; + oldValues?: Record; + newValues?: Record; + statusCode?: number; + metadata?: Record; + } = {}, + ): Promise { + const clientInfo = this.extractClientInfo(client); + + await this.log( + { + action, + resource: options.resource, + resourceId: options.resourceId, + oldValues: options.oldValues, + newValues: options.newValues, + ipAddress: clientInfo.ipAddress, + userAgent: clientInfo.userAgent, + method: action, // For WebSocket, method is the event name + endpoint: options.resourceId ? `room:${options.resourceId}` : undefined, + statusCode: options.statusCode || 200, + metadata: options.metadata, + userId: options.userId, + }, + client, + ); + } + + /** + * Log an HTTP request (for future use) + */ + async logHttpRequest( + action: string, + req: any, + options: { + userId?: string; + resource?: string; + resourceId?: string; + oldValues?: Record; + newValues?: Record; + statusCode?: number; + metadata?: Record; + } = {}, + ): Promise { + const ipAddress = + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.ip || + req.connection?.remoteAddress || + undefined; + + const userAgent = req.headers['user-agent'] || undefined; + + await this.log({ + action, + resource: options.resource, + resourceId: options.resourceId, + oldValues: options.oldValues, + newValues: options.newValues, + ipAddress, + userAgent, + method: req.method, + endpoint: req.path || req.url, + statusCode: options.statusCode || 200, + metadata: options.metadata, + userId: options.userId, + }); + } +} + + + + + + + + + + + + + + + diff --git a/src/common/tools/encryption-helper.ts b/src/common/tools/encryption-helper.ts new file mode 100644 index 0000000..e1fcbfe --- /dev/null +++ b/src/common/tools/encryption-helper.ts @@ -0,0 +1,39 @@ +import * as crypto from 'node:crypto'; + +export class EncryptionHelper { + private static enc_algorithm = 'aes-256-cbc'; + private static enc_key = Buffer.from( + '8l4VcgZ7b2pysZznW025123kjhUIOHGF', + 'utf8', + ); + private static enc_iv = Buffer.from('-y/B?D(R+R;lQeTh', 'utf8'); + + public static initialize(algorithm: string, key: string, iv: string): void { + EncryptionHelper.enc_algorithm = algorithm; + EncryptionHelper.enc_key = Buffer.from(key, 'utf8'); + EncryptionHelper.enc_iv = Buffer.from(iv, 'utf8'); + } + + public static encrypt(text): string { + const cipher = crypto.createCipheriv( + this.enc_algorithm, + Buffer.from(this.enc_key), + this.enc_iv, + ); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return encrypted.toString('hex'); + } + + public static decrypt(text): string { + const encryptedText = Buffer.from(text, 'hex'); + const decipher = crypto.createDecipheriv( + this.enc_algorithm, + Buffer.from(this.enc_key), + this.enc_iv, + ); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); + } +} diff --git a/src/common/tools/time-helper.ts b/src/common/tools/time-helper.ts new file mode 100644 index 0000000..09e1ae5 --- /dev/null +++ b/src/common/tools/time-helper.ts @@ -0,0 +1,95 @@ +import * as jalaliMoment from 'moment-jalaali'; + +export class TimeHelper { + public static unix2Date(unix, time = false) { + let date = new Date(unix * 1000); + if (time) + return `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`; + return date; + } + public static isValid(date, type = 'miladi') { + if (type === 'miladi') { + const valid = Date.parse(date); + return !isNaN(valid) && valid > 0; + } + return true; + } + public static miladi2jalali(date, format = 'jYYYY-jMM-jDD') { + if (!this.isValid(date)) return date; + + date = jalaliMoment(date); + return date.format(format); + } + + public static unix2PersianTimeAndDate(date) { + date = this.unix2Date(date); + + // Get time in HH:mm format + let time = `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`; + + // Convert to Jalali date with double-digit months and days + let jalaliDate = this.miladi2jalali(date, 'jYYYY-jMM-jDD'); + + return [time, jalaliDate]; + } + + public static iso2PersianTimeAndDate(isoDate: string | Date): [string, string] { + const date = typeof isoDate === 'string' ? new Date(isoDate) : isoDate; + + // Get time in HH:mm format + let time = `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`; + + // Convert to Jalali date with double-digit months and days + let jalaliDate = this.miladi2jalali(date, 'jYYYY-jMM-jDD'); + + return [time, jalaliDate]; + } + + public static readonly normalizeDate = (date: string) => { + const [year, month, day] = date.split('/'); + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + }; + + public static jalaliToISO(jalaliDate: string): string { + // Convert Jalali date to Gregorian + const [year, month, day] = jalaliDate.split('/'); + const gregorianDate = jalaliMoment( + `${year}/${month}/${day}`, + 'jYYYY/jMM/jDD', + ).format('YYYY-MM-DD'); + + // Create a new date object with the converted date + const date = new Date(gregorianDate); + + // Format to ISO string + return date.toISOString(); + } + + public static divideDateRange( + startDate: string, + endDate: string, + ): { start: string; end: string }[] { + // Convert dates to moment objects + const start = jalaliMoment(startDate, 'jYYYY/jMM/jDD'); + const end = jalaliMoment(endDate, 'jYYYY/jMM/jDD'); + + // Calculate total days + const totalDays = end.diff(start, 'days'); + const segmentDays = Math.floor(totalDays / 5); + + const segments: { start: string; end: string }[] = []; + let currentStart = start.clone(); + + for (let i = 0; i < 5; i++) { + const segmentEnd = + i === 4 ? end : currentStart.clone().add(segmentDays, 'days'); + segments.push({ + start: currentStart.format('jYYYY/jMM/jDD'), + end: segmentEnd.format('jYYYY/jMM/jDD'), + }); + currentStart = segmentEnd.clone().add(1, 'days'); + } + + return segments; + } +} diff --git a/src/common/types/categories.type.ts b/src/common/types/categories.type.ts new file mode 100644 index 0000000..7546e23 --- /dev/null +++ b/src/common/types/categories.type.ts @@ -0,0 +1,12 @@ +export enum CategoriesEnum { + carInsurance = 'carInsurance', + liabilityInsurance = 'liabilityInsurance', + healthInsurance = 'healthInsurance', + lifeInsurance = 'lifeInsurance', + motorcycleInsurance = 'motorcycleInsurance', + fireInsurance = 'fireInsurance', + travelInsurance = 'travelInsurance', + transportInsurance = 'transportInsurance', + incidentsInsurance = 'incidentsInsurance', + general = 'general', +} diff --git a/src/common/types/expert-prepared-message-category.type.ts b/src/common/types/expert-prepared-message-category.type.ts new file mode 100644 index 0000000..83e6972 --- /dev/null +++ b/src/common/types/expert-prepared-message-category.type.ts @@ -0,0 +1,24 @@ +export enum ExpertPreparedMessageCategory { + Greetings = 'greetings', + QuickAnswers = 'quick_answers', + Closing = 'closing', + FollowUp = 'follow_up', + Apology = 'apology', + Other = 'other', +} + +export const EXPERT_PREPARED_MESSAGE_CATEGORIES = Object.values( + ExpertPreparedMessageCategory, +); + +export const EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS: Record< + ExpertPreparedMessageCategory, + string +> = { + [ExpertPreparedMessageCategory.Greetings]: 'Greetings', + [ExpertPreparedMessageCategory.QuickAnswers]: 'Quick answers', + [ExpertPreparedMessageCategory.Closing]: 'Closing', + [ExpertPreparedMessageCategory.FollowUp]: 'Follow-up', + [ExpertPreparedMessageCategory.Apology]: 'Apology', + [ExpertPreparedMessageCategory.Other]: 'Other', +}; diff --git a/src/common/types/expert.type.ts b/src/common/types/expert.type.ts new file mode 100644 index 0000000..51476ce --- /dev/null +++ b/src/common/types/expert.type.ts @@ -0,0 +1,4 @@ +export interface ExpertData { + activeSessions: string; + maxSessions: string; +} diff --git a/src/common/types/func.type.ts b/src/common/types/func.type.ts new file mode 100644 index 0000000..4dfbfc0 --- /dev/null +++ b/src/common/types/func.type.ts @@ -0,0 +1,6 @@ +export enum funcsEnum { + create = 'create', + update = 'update', + replace = 'replace', + } + \ No newline at end of file diff --git a/src/common/types/permissions.catalog.ts b/src/common/types/permissions.catalog.ts new file mode 100644 index 0000000..3f4e601 --- /dev/null +++ b/src/common/types/permissions.catalog.ts @@ -0,0 +1,153 @@ +import { BadRequestException } from '@nestjs/common'; +import { Role } from './role.type'; + +/** + * Code-defined permission catalog (ADR 0001). + * Hybrid: coarse modules + fine keys where defaults differ. + */ +export enum Permission { + ProfileRead = 'profile.read', + ProfileWrite = 'profile.write', + + StaffCreateExpert = 'staff.create_expert', + StaffCreateSupervisor = 'staff.create_supervisor', + StaffCreateAdmin = 'staff.create_admin', + + DashboardView = 'dashboard.view', + + ConversationsOwn = 'conversations.own', + ConversationsAll = 'conversations.all', + ConversationsExport = 'conversations.export', + ConversationsRate = 'conversations.rate', + + UsersList = 'users.list', + UsersExport = 'users.export', + ExpertsList = 'experts.list', + ExpertsReport = 'experts.report', + + ReportsView = 'reports.view', + ReportsExpertTiming = 'reports.expert_timing', + + BusinessHoursManage = 'business_hours.manage', + ClientsManage = 'clients.manage', + CategoriesManage = 'categories.manage', + + PreparedMessagesManage = 'prepared_messages.manage', + PreparedMessagesRead = 'prepared_messages.read', + + DictionariesRead = 'dictionaries.read', + DictionariesWrite = 'dictionaries.write', + DictionariesCreate = 'dictionaries.create', + DictionariesApprove = 'dictionaries.approve', + DictionariesAssets = 'dictionaries.assets', + /** Admin-style direct question edit without modification request. */ + DictionariesDirectEdit = 'dictionaries.direct_edit', + + AttachmentsExpert = 'attachments.expert', +} + +export const ALL_ASSIGNABLE_PERMISSIONS: readonly Permission[] = + Object.values(Permission); + +/** Permissions that may never appear on role templates or overrides. */ +export const OWNER_ONLY_META = { + RolesManage: 'acl.roles.manage', + OverridesManage: 'acl.overrides.manage', +} as const; + +export function isAssignablePermission(key: string): key is Permission { + return (ALL_ASSIGNABLE_PERMISSIONS as readonly string[]).includes(key); +} + +export function assertAssignablePermissions(keys: string[]): Permission[] { + const invalid = keys.filter((k) => !isAssignablePermission(k)); + if (invalid.length) { + throw new BadRequestException( + `Unknown or non-assignable permissions: ${invalid.join(', ')}`, + ); + } + return keys as Permission[]; +} + +const ADMIN_DEFAULT: Permission[] = [ + Permission.ProfileRead, + Permission.ProfileWrite, + Permission.StaffCreateExpert, + Permission.DashboardView, + Permission.ConversationsAll, + Permission.ConversationsExport, + Permission.ConversationsRate, + Permission.UsersList, + Permission.UsersExport, + Permission.ExpertsList, + Permission.ExpertsReport, + Permission.ReportsView, + Permission.ReportsExpertTiming, + Permission.BusinessHoursManage, + Permission.ClientsManage, + Permission.CategoriesManage, + Permission.PreparedMessagesManage, + Permission.DictionariesRead, + Permission.DictionariesWrite, + Permission.DictionariesCreate, + Permission.DictionariesApprove, + Permission.DictionariesAssets, + Permission.DictionariesDirectEdit, +]; + +const SUPERVISOR_DEFAULT: Permission[] = [ + Permission.ProfileRead, + Permission.ProfileWrite, + Permission.StaffCreateExpert, + Permission.DashboardView, + Permission.ConversationsAll, + Permission.ConversationsExport, + Permission.ConversationsRate, + Permission.UsersList, + Permission.UsersExport, + Permission.ExpertsList, + Permission.ExpertsReport, + Permission.ReportsView, + Permission.ReportsExpertTiming, + Permission.BusinessHoursManage, + Permission.ClientsManage, + Permission.CategoriesManage, + Permission.PreparedMessagesManage, +]; + +const EXPERT_DEFAULT: Permission[] = [ + Permission.ProfileRead, + Permission.ProfileWrite, + Permission.ConversationsOwn, + Permission.UsersList, + Permission.UsersExport, + Permission.ReportsExpertTiming, + Permission.PreparedMessagesRead, + Permission.DictionariesRead, + Permission.DictionariesWrite, + Permission.AttachmentsExpert, +]; + +export const DEFAULT_ROLE_PERMISSIONS: Record< + Role.Admin | Role.Supervisor | Role.Expert, + Permission[] +> = { + [Role.Admin]: ADMIN_DEFAULT, + [Role.Supervisor]: SUPERVISOR_DEFAULT, + [Role.Expert]: EXPERT_DEFAULT, +}; + +export function createPermissionForRole( + role: string, +): Permission | null { + switch (role) { + case Role.Expert: + return Permission.StaffCreateExpert; + case Role.Supervisor: + return Permission.StaffCreateSupervisor; + case Role.Admin: + return Permission.StaffCreateAdmin; + default: + return null; + } +} diff --git a/src/common/types/react.type.ts b/src/common/types/react.type.ts new file mode 100644 index 0000000..05c2469 --- /dev/null +++ b/src/common/types/react.type.ts @@ -0,0 +1,5 @@ +export enum ReactEnum { + like = 'Like', + dislike = 'Dislike', + nothing = 'Nothing', +} diff --git a/src/common/types/role.type.ts b/src/common/types/role.type.ts new file mode 100644 index 0000000..b9dd4ae --- /dev/null +++ b/src/common/types/role.type.ts @@ -0,0 +1,22 @@ +export enum Role { + User = 'user', + Admin = 'admin', + Expert = 'expert', + Supervisor = 'supervisor', + Owner = 'owner', +} + +/** System staff roles that cannot be deleted/renamed. */ +export const SYSTEM_STAFF_ROLES: readonly Role[] = [ + Role.Admin, + Role.Expert, + Role.Supervisor, +] as const; + +export function isOwnerRole(role: string | Role | undefined | null): boolean { + return role === Role.Owner; +} + +export function isSystemStaffRole(role: string): boolean { + return (SYSTEM_STAFF_ROLES as readonly string[]).includes(role); +} diff --git a/src/common/types/sender.type.ts b/src/common/types/sender.type.ts new file mode 100644 index 0000000..8b0bdd1 --- /dev/null +++ b/src/common/types/sender.type.ts @@ -0,0 +1,5 @@ +export enum Sender { + User = 'User', + Bot = 'Bot', + Expert = 'Expert', +} diff --git a/src/conversation/conversation.controller.ts b/src/conversation/conversation.controller.ts new file mode 100644 index 0000000..98ea1ce --- /dev/null +++ b/src/conversation/conversation.controller.ts @@ -0,0 +1,241 @@ +import { + Controller, + Get, + Post, + Body, + Param, + UseGuards, + Query, + Res, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { ConversationService } from './conversation.service'; +import { AdminRatesBot, AdminRatesExpert } from './dto/admin.dto'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@ApiTags('conversation') +@Controller('conversation') +export class ConversationController { + constructor(private readonly conversationService: ConversationService) {} + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: + 'Chat histories in expert panel (Expert sees his own conversations list) + filters(user mobile,date)', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @Get('/expert') + findChatHistory( + @CurrentIdentity() currentUser: any, + @Query('user') user?: string, + @Query('date') date?: string, + ) { + return this.conversationService.expertSessions({ user, date }, currentUser); + } + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: + 'Expert can start a conversation with a user and calls this api to have a start point.', + }) + @Get('/expert/start/:sessionId') + startConversationApi(@Param('sessionId') sessionId?: string) { + return this.conversationService.startConversation(sessionId); + } + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: 'Single chat history', + }) + @ApiParam({ name: 'sessionId' }) + @Get('/expert/:sessionId') + findSignleChatHistory(@Param('sessionId') sessionId?: string) { + return this.conversationService.singleChatExpert(sessionId); + } + + + @Permissions(Permission.ConversationsAll) + @ApiOperation({ + summary: + 'Chat histories in expert panel (Expert sees his own conversations list) + filters(user mobile,date)', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: "Filter by the expert's email OR their name/family name", + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ["all", "bot", "online"], + description: 'Filter sessions by expert status: all, bot-handled, or online-expert-handled', + example: 'all', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (starts from 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of items per page', + example: 50, + }) + @Get('/admin') + chatHistoryAdmin( + @Query('user') user?: string, + @Query('date') date?: string, + @Query('expert') expert?: string, + @Query('status') status?: "all" | "bot" | "online", + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? parseInt(limit, 10) : 50; + + return this.conversationService.expertSessionsByAdmin({ + user, + date, + expert, + status, + page: pageNum, + limit: limitNum, + }); + } + + @Permissions(Permission.ConversationsExport) + @ApiOperation({ + summary: 'Export chat history to Excel with all messages and details', + description: + 'Exports all chat sessions matching the filters to an Excel file with two sheets: ' + + '1) Sessions Overview (with user info, expert info, ratings) ' + + '2) Messages Detail (all messages with sender info)', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Filter by user mobile number or name', + example: '09226187419', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: "Filter by the expert's email OR their name/family name", + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ["all", "bot", "online"], + description: 'Filter sessions by expert status: all, bot-handled, or online-expert-handled', + example: 'all', + }) + @Get('/admin/export-excel') + async exportChatHistory( + @Query('user') user?: string, + @Query('date') date?: string, + @Query('expert') expert?: string, + @Query('status') status?: "all" | "bot" | "online", + @Res() res?: Response, + ) { + const buffer = await this.conversationService.exportChatHistoryToExcel({ + user, + date, + expert, + status, + }); + + const filename = `chat-history-${new Date().getTime()}.xlsx`; + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + + @Permissions(Permission.ConversationsAll) + @ApiOperation({ + summary: 'Single chat history admin', + }) + @ApiParam({ name: 'sessionId' }) + @Get('/admin/:sessionId') + findSignleChatHistoryAdmin(@Param('sessionId') sessionId?: string) { + return this.conversationService.singleChatExpert(sessionId); + } + + @Permissions(Permission.ConversationsRate) + @ApiOperation({ + summary: 'Single chat history admin', + }) + @Post('/admin/rateExpert') + adminRateExpert(@Body() body: AdminRatesExpert) { + return this.conversationService.adminRateExpert(body); + } + + @Permissions(Permission.ConversationsRate) + @ApiOperation({ + summary: 'Single chat history admin', + }) + @Post('/admin/rateBot') + adminRateBot(@Body() body: AdminRatesBot) { + return this.conversationService.adminRateBot(body); + } +} diff --git a/src/conversation/conversation.module.ts b/src/conversation/conversation.module.ts new file mode 100644 index 0000000..a9b4be8 --- /dev/null +++ b/src/conversation/conversation.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { ConversationController } from './conversation.controller'; +import { ConversationService } from './conversation.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [ConversationController], + providers: [ConversationService], + exports: [ConversationService], +}) +export class ConversationModule {} diff --git a/src/conversation/conversation.service.ts b/src/conversation/conversation.service.ts new file mode 100644 index 0000000..5d2df7c --- /dev/null +++ b/src/conversation/conversation.service.ts @@ -0,0 +1,643 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO, PageMetaDto, PageOptionsDto } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Role } from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { AdminRatesExpert } from './dto/admin.dto'; +import * as ExcelJS from 'exceljs'; + +@Injectable() +export class ConversationService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(AdminModel.name) private readonly admin: Model, + ) {} + async expertSessions(filters, currentUser) { + try { + const query: any = { + connectedToExpert: true, + expert: currentUser.username, + }; + + // Date filter + if (filters.date) { + const [startDate, endDate] = filters.date.split('-'); + + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + + // Create date objects + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + // Set start to beginning of day and end to end of day + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // User mobile filter + if (filters.user) { + const usersWithMobile = await this.user.find({ mobile: filters.user }); + const userIds = usersWithMobile.map((user) => user._id); + query.userId = { $in: userIds }; + } + + const sessions = await this.session.find(query); + + const structuredData = sessions.map((session) => ({ + _id: session._id, + chatTitle: session.chatTitle, + subTitle: session.messages[1]?.text || '', + expert: session.expert, + createdAt: session.createdAt, + createdISO: session.createdISO, + })); + + return new BaseResponseDTO(HttpStatus.OK, 'success', structuredData); + } catch (err) { + console.error('Error in expertSessions:', err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async startConversation( + sessionId: string, + forEnd: boolean = false, + ): Promise { + try { + if (!sessionId) { + throw new HttpException('session_id_required', HttpStatus.BAD_REQUEST); + } + const updateField = forEnd ? 'onlineEndDate' : 'onlineStartDate'; + const updateData = forEnd + ? { + [updateField]: new Date(), + onlineChatClosed: true, + chatClosed: true, + } + : { + [updateField]: new Date(), + }; + + const updatedSession = await this.session.findOneAndUpdate( + { _id: new Types.ObjectId(sessionId) }, + { $set: updateData }, + { new: true }, + ); + + if (!updatedSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + return new BaseResponseDTO( + HttpStatus.OK, + forEnd ? 'conversation_ended' : 'conversation_started', + { + sessionId: updatedSession._id, + [updateField]: updatedSession[updateField], + }, + ); + } catch (error) { + console.error('Start Conversation Error:', error); + + if (error instanceof HttpException) { + throw new BaseResponseDTO(error.getStatus(), error.message, null); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'error_updating_conversation', + null, + ); + } + } + + async singleChatExpert(sessionId) { + try { + const session = await this.session.findOne({ _id: sessionId }); + if (!session) + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + const user = await this.user.findOne({ _id: session.userId }); + console.log(user); + return new BaseResponseDTO(HttpStatus.OK, 'success', { + _id: session._id, + user: user.mobile, + name: user.name, + family: user.family, + chatTitle: session.chatTitle, + expert: session.expert, + expertRate: session.expertRate, + adminRateToExpert: session.adminRateToExpert, + adminRateToBot: session.adminRateToBot, + messages: session.messages, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async expertSessionsByAdmin(filters: { + user?: string; + date?: string; + expert?: string; + status?: "all" | "bot" | "online"; + page?: number; + limit?: number; + }) { + try { + const query: any = {}; + + // Status filter - apply at database level for efficiency + if (filters.status === 'bot') { + // Bot-handled sessions: NOT connected to expert + query.connectedToExpert = false; + } else if (filters.status === 'online') { + // Expert-handled sessions: connected to expert + query.connectedToExpert = true; + } + // If status is 'all' or not provided, don't add connectedToExpert filter + + // Date filter + if (filters.date) { + const dateParts = filters.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new HttpException( + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + HttpStatus.BAD_REQUEST + ); + } + + const [startDate, endDate] = dateParts; + + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // User mobile filter + if (filters.user) { + const userIdentifier = filters.user.trim(); + const isNameSearch = /\D/.test(userIdentifier); + + if (isNameSearch) { + const normalizedName = userIdentifier.replace(/ي/g, 'ی').replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const usersFound = await this.user.find({ + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (usersFound.length > 0) { + const userIds = usersFound.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + // Return empty result with pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: filters.page || 1, + take: filters.limit || 50, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: 0 }); + return new BaseResponseDTO(HttpStatus.OK, 'success', [], meta); + } + + } else { + const usersWithMobile = await this.user.find({ mobile: userIdentifier }); + if (usersWithMobile.length > 0) { + const userIds = usersWithMobile.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + // Return empty result with pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: filters.page || 1, + take: filters.limit || 50, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: 0 }); + return new BaseResponseDTO(HttpStatus.OK, 'success', [], meta); + } + } + } + + // Expert filter + if (filters.expert) { + const identifier = filters.expert.trim(); + const isEmail = /@/.test(identifier); + + if (isEmail) { + query.expert = identifier; + } else { + const normalizedName = identifier + .replace(/ي/g, 'ی') + .replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const expertsFound = await this.admin.find({ + role: Role.Expert, + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (expertsFound.length > 0) { + const expertUsernames = expertsFound.map( + (expert) => expert.username, + ); + query.expert = { $in: expertUsernames }; + } else { + // Return empty result with pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: filters.page || 1, + take: filters.limit || 50, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: 0 }); + return new BaseResponseDTO(HttpStatus.OK, 'success', [], meta); + } + } + } + + // Pagination setup + const page = filters.page || 1; + const limit = filters.limit || 50; + const skip = (page - 1) * limit; + + // Get total count for pagination metadata + const totalCount = await this.session.countDocuments(query).exec(); + + // Fetch sessions with pagination, lean for performance, and sort by creation date (newest first) + const sessions = await this.session + .find(query) + .select('_id chatTitle messages expert createdAt createdISO expertRate') + .sort({ createdISO: -1 }) // Sort by creation date descending (newest first) + .skip(skip) + .limit(limit) + .lean() + .exec(); + + const structuredData = sessions.map((session) => ({ + _id: session._id, + chatTitle: session.chatTitle, + subTitle: session.messages?.[1]?.text || '', + expert: session.expert, + createdAt: session.createdAt, + createdISO: session.createdISO, + expertRate: session.expertRate || null, + })); + + // Create pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: page, + take: limit, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: totalCount }); + + return new BaseResponseDTO(HttpStatus.OK, 'success', structuredData, meta); + } catch (err) { + console.error('Error in expertSessionsByAdmin:', err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async adminRateExpert(body: AdminRatesExpert) { + try { + const updateSession = await this.session.findOneAndUpdate( + { + _id: new Types.ObjectId(body.sessionId), + }, + { + adminRateToExpert: body.adminRate, + }, + { + new: true, + }, + ); + if (!updateSession) + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + + return new BaseResponseDTO(HttpStatus.OK, 'success', null); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async adminRateBot(body: AdminRatesExpert) { + try { + const updateSession = await this.session.findOneAndUpdate( + { + _id: new Types.ObjectId(body.sessionId), + }, + { + adminRateToBot: body.adminRate, + }, + { + new: true, + }, + ); + if (!updateSession) + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + + return new BaseResponseDTO(HttpStatus.OK, 'success', null); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async exportChatHistoryToExcel(filters: { + user?: string; + date?: string; + expert?: string; + status?: "all" | "bot" | "online"; + }): Promise { + try { + // Build the same query as expertSessionsByAdmin + const query: any = {}; + + // Status filter + if (filters.status === 'bot') { + query.connectedToExpert = false; + } else if (filters.status === 'online') { + query.connectedToExpert = true; + } + + // Date filter + if (filters.date) { + const dateParts = filters.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new HttpException( + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + HttpStatus.BAD_REQUEST + ); + } + + const [startDate, endDate] = dateParts; + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // User mobile/name filter + if (filters.user) { + const userIdentifier = filters.user.trim(); + const isNameSearch = /\D/.test(userIdentifier); + + if (isNameSearch) { + const normalizedName = userIdentifier.replace(/ي/g, 'ی').replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const usersFound = await this.user.find({ + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (usersFound.length > 0) { + const userIds = usersFound.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + // No users found, return empty Excel + return this.createEmptyChatHistoryExcel(); + } + } else { + const usersWithMobile = await this.user.find({ mobile: userIdentifier }); + if (usersWithMobile.length > 0) { + const userIds = usersWithMobile.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + return this.createEmptyChatHistoryExcel(); + } + } + } + + // Expert filter + if (filters.expert) { + const identifier = filters.expert.trim(); + const isEmail = /@/.test(identifier); + + if (isEmail) { + query.expert = identifier; + } else { + const normalizedName = identifier + .replace(/ي/g, 'ی') + .replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const expertsFound = await this.admin.find({ + role: Role.Expert, + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (expertsFound.length > 0) { + const expertUsernames = expertsFound.map( + (expert) => expert.username, + ); + query.expert = { $in: expertUsernames }; + } else { + return this.createEmptyChatHistoryExcel(); + } + } + } + + // Fetch all sessions with full data + const sessions = await this.session.find(query).lean().exec(); + + // Fetch all unique user IDs and expert usernames + const userIds = [...new Set(sessions.map(s => s.userId.toString()))]; + const expertUsernames = [...new Set(sessions.map(s => s.expert).filter(Boolean))]; + + // Fetch user and expert details + const users = await this.user.find({ _id: { $in: userIds.map(id => new Types.ObjectId(id)) } }).lean().exec(); + const experts = await this.admin.find({ username: { $in: expertUsernames } }).lean().exec(); + + // Create maps for quick lookup + const userMap = new Map(users.map(u => [u._id.toString(), u])); + const expertMap = new Map(experts.map(e => [e.username, e])); + + // Create workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + // Create Sessions Overview sheet + const overviewSheet = workbook.addWorksheet('نمای کلی گفتگوها'); + + // Style the header + overviewSheet.columns = [ + { header: 'شناسه گفتگو', key: 'sessionId', width: 25 }, + { header: 'عنوان گفتگو', key: 'chatTitle', width: 30 }, + { header: 'نام کاربر', key: 'userName', width: 20 }, + { header: 'موبایل کاربر', key: 'userMobile', width: 15 }, + { header: 'کد ملی کاربر', key: 'userNationalCode', width: 15 }, + { header: 'نام کارشناس', key: 'expertName', width: 20 }, + { header: 'موبایل کارشناس', key: 'expertMobile', width: 15 }, + { header: 'متصل به کارشناس', key: 'connectedToExpert', width: 15 }, + { header: 'امتیاز کارشناس', key: 'expertRate', width: 12 }, + { header: 'نظر ادمین به کارشناس', key: 'adminRateExpert', width: 18 }, + { header: 'نظر ادمین به ربات', key: 'adminRateBot', width: 18 }, + { header: 'تعداد پیام‌ها', key: 'messageCount', width: 12 }, + { header: 'تاریخ ایجاد', key: 'createdDate', width: 15 }, + { header: 'ساعت ایجاد', key: 'createdTime', width: 12 }, + { header: 'تاریخ شروع آنلاین', key: 'onlineStartDate', width: 20 }, + { header: 'تاریخ پایان آنلاین', key: 'onlineEndDate', width: 20 }, + { header: 'وضعیت بسته شدن', key: 'chatClosed', width: 15 }, + ]; + + // Apply header styling + overviewSheet.getRow(1).font = { bold: true, size: 11 }; + overviewSheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' }; + overviewSheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' } + }; + overviewSheet.getRow(1).font = { ...overviewSheet.getRow(1).font, color: { argb: 'FFFFFFFF' } }; + + // Add session data + sessions.forEach(session => { + const user = userMap.get(session.userId.toString()); + const expert = session.expert ? expertMap.get(session.expert) : null; + + overviewSheet.addRow({ + sessionId: session._id.toString(), + chatTitle: session.chatTitle || '', + userName: user ? `${user.name || ''} ${user.family || ''}`.trim() : 'نامشخص', + userMobile: user?.mobile || 'نامشخص', + userNationalCode: user?.nationalCode || 'نامشخص', + expertName: expert ? `${expert.name || ''} ${expert.family || ''}`.trim() : 'ربات', + expertMobile: expert?.mobile || '-', + connectedToExpert: session.connectedToExpert ? 'بله' : 'خیر', + expertRate: session.expertRate || '-', + adminRateExpert: session.adminRateToExpert === true ? 'لایک' : session.adminRateToExpert === false ? 'دیسلایک' : '-', + adminRateBot: session.adminRateToBot === true ? 'لایک' : session.adminRateToBot === false ? 'دیسلایک' : '-', + messageCount: session.messages?.length || 0, + createdDate: session.createdAt?.[1] || '', + createdTime: session.createdAt?.[0] || '', + onlineStartDate: session.onlineStartDate ? new Date(session.onlineStartDate).toLocaleString('fa-IR') : '-', + onlineEndDate: session.onlineEndDate ? new Date(session.onlineEndDate).toLocaleString('fa-IR') : '-', + chatClosed: session.chatClosed ? 'بسته شده' : 'باز', + }); + }); + + // Create Messages Detail sheet + const messagesSheet = workbook.addWorksheet('جزئیات پیام‌ها'); + + messagesSheet.columns = [ + { header: 'شناسه گفتگو', key: 'sessionId', width: 25 }, + { header: 'عنوان گفتگو', key: 'chatTitle', width: 30 }, + { header: 'شماره پیام', key: 'messageNumber', width: 10 }, + { header: 'فرستنده', key: 'sender', width: 15 }, + { header: 'متن پیام', key: 'messageText', width: 60 }, + { header: 'واکنش', key: 'react', width: 12 }, + { header: 'ویرایش شده', key: 'edited', width: 12 }, + { header: 'تاریخ ارسال', key: 'messageDate', width: 15 }, + { header: 'ساعت ارسال', key: 'messageTime', width: 12 }, + ]; + + // Apply header styling + messagesSheet.getRow(1).font = { bold: true, size: 11 }; + messagesSheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' }; + messagesSheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF70AD47' } + }; + messagesSheet.getRow(1).font = { ...messagesSheet.getRow(1).font, color: { argb: 'FFFFFFFF' } }; + + // Add message data + sessions.forEach(session => { + if (session.messages && session.messages.length > 0) { + session.messages.forEach((message, index) => { + let senderDisplay = ''; + const senderStr = String(message.sender).toLowerCase(); + if (senderStr === 'user') { + const user = userMap.get(session.userId.toString()); + senderDisplay = user ? `کاربر: ${user.name || ''} ${user.family || ''}`.trim() : 'کاربر'; + } else if (senderStr === 'expert') { + const expert = session.expert ? expertMap.get(session.expert) : null; + senderDisplay = expert ? `کارشناس: ${expert.name || ''} ${expert.family || ''}`.trim() : 'کارشناس'; + } else if (senderStr === 'bot') { + senderDisplay = 'ربات'; + } else { + senderDisplay = String(message.sender); + } + + messagesSheet.addRow({ + sessionId: session._id.toString(), + chatTitle: session.chatTitle || '', + messageNumber: index + 1, + sender: senderDisplay, + messageText: message.text || '', + react: message.react || 'Nothing', + edited: message.edited ? 'بله' : 'خیر', + messageDate: message.createdAt?.[1] || '', + messageTime: message.createdAt?.[0] || '', + }); + }); + } + }); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + + } catch (err) { + console.error('Error in exportChatHistoryToExcel:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.message || 'Internal Server Error', + null, + ); + } + } + + private async createEmptyChatHistoryExcel(): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet('نمای کلی گفتگوها'); + sheet.addRow(['هیچ گفتگویی با فیلترهای انتخابی یافت نشد']); + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } +} diff --git a/src/conversation/dto/admin.dto.ts b/src/conversation/dto/admin.dto.ts new file mode 100644 index 0000000..aafe925 --- /dev/null +++ b/src/conversation/dto/admin.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AdminRatesExpert { + @ApiProperty({ + required: false, + type: 'string', + description: 'chat session id', + example: '', + }) + sessionId: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: + 'admin up-vote or down-vote the expert after they see experts chat with user', + example: 'true', + }) + adminRate: boolean; +} + +export class AdminRatesBot { + @ApiProperty({ + required: false, + type: 'string', + description: 'chat session id', + example: '', + }) + sessionId: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: + 'admin up-vote or down-vote the bot after they see bots chat with user', + example: 'true', + }) + adminRate: boolean; +} diff --git a/src/conversation/dto/create-conversation.dto.ts b/src/conversation/dto/create-conversation.dto.ts new file mode 100644 index 0000000..8b3bd5d --- /dev/null +++ b/src/conversation/dto/create-conversation.dto.ts @@ -0,0 +1 @@ +export class CreateConversationDto {} diff --git a/src/conversation/dto/update-conversation.dto.ts b/src/conversation/dto/update-conversation.dto.ts new file mode 100644 index 0000000..cd3976e --- /dev/null +++ b/src/conversation/dto/update-conversation.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateConversationDto } from './create-conversation.dto'; + +export class UpdateConversationDto extends PartialType(CreateConversationDto) {} diff --git a/src/database/database.module.ts b/src/database/database.module.ts new file mode 100644 index 0000000..665266b --- /dev/null +++ b/src/database/database.module.ts @@ -0,0 +1,149 @@ +import { HttpModule } from '@nestjs/axios'; +import { forwardRef, Module, OnModuleInit } from '@nestjs/common'; +import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +import { ConfigService } from '@nestjs/config'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { Model } from 'mongoose'; +import { AuthService } from 'src/auth/auth.service'; +import { AdminService } from 'src/api/admin/admin.service'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { SamanSmsService } from 'src/externals/sms/saman.service'; +import { SsoService } from 'src/externals/sso/sso.service'; +import { UploadModule } from 'src/upload/upload.module'; +import { PoliciesModule } from 'src/policies/policies.module'; +import { AdminModel, AdminSchema } from './model/admin.model'; +import { CategoriesModel, CategoriesSchema } from './model/categories.model'; +import { ClientModel, ClientSchema } from './model/client.model'; +import { + DictionariesModel, + DictionariesSchema, +} from './model/dictionaries.model'; +import { + ExternalTokensModel, + ExternalTokenSchema, +} from './model/externalTokens.model'; +import { FileUploadModel, FileUploadSchema } from './model/fileUpload.model'; +import { + QuestionModificationModel, + QuestionModificationSchema, +} from './model/questionModification.model'; +import { SessionModel, SessionSchema } from './model/sessions.model'; +import { UserModel, UserSchema } from './model/user.model'; +import { ReactsModel, ReactSchema } from './model/botReact.model'; +import { + BusinessHoursModel, + BusinessHoursSchema, +} from './model/business-hours.model'; +import { + ReassignedLogsModel, + ReassignedLogsSchema, +} from './model/reassigned-logs.model'; +import { + AuditLogModel, + AuditLogSchema, +} from './model/audit-log.model'; +import { + ChatMessageAttachmentModel, + ChatMessageAttachmentSchema, +} from './model/chat-message-attachment.model'; +import { + DictionaryFileAssetModel, + DictionaryFileAssetSchema, +} from './model/dictionary-file-asset.model'; +import { + ExpertPreparedMessageModel, + ExpertPreparedMessageSchema, +} from './model/expert-prepared-message.model'; +import { + StaffRoleModel, + StaffRoleSchema, +} from './model/staff-role.model'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { PermissionsService } from 'src/acl/permissions.service'; + +@Module({ + imports: [ + RedisModule.forRootAsync({ + useFactory: () => ({ + type: 'single', // or 'cluster' + url: process.env.REDIS_URL, + }), + }), + HttpModule.register({ + timeout: 5000, + maxRedirects: 5, + }), + MongooseModule.forRootAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + uri: `mongodb://${configService.get('MONGO_INITDB_ROOT_USERNAME')}:${configService.get('MONGO_INITDB_ROOT_PASSWORD')}@${configService.get('MONGO_HOST')}:${configService.get('MONGO_PORT')}/chat_bot?authMechanism=DEFAULT&authSource=admin`, + }), + }), + MongooseModule.forFeature([ + { name: UserModel.name, schema: UserSchema }, + { name: SessionModel.name, schema: SessionSchema }, + { name: ClientModel.name, schema: ClientSchema }, + { name: AdminModel.name, schema: AdminSchema }, + { name: DictionariesModel.name, schema: DictionariesSchema }, + { name: FileUploadModel.name, schema: FileUploadSchema }, + { name: CategoriesModel.name, schema: CategoriesSchema }, + { name: ExternalTokensModel.name, schema: ExternalTokenSchema }, + { + name: QuestionModificationModel.name, + schema: QuestionModificationSchema, + }, + { name: ReactsModel.name, schema: ReactSchema }, + { name: BusinessHoursModel.name, schema: BusinessHoursSchema }, + { name: ReassignedLogsModel.name, schema: ReassignedLogsSchema }, + { name: AuditLogModel.name, schema: AuditLogSchema }, + { + name: DictionaryFileAssetModel.name, + schema: DictionaryFileAssetSchema, + }, + { + name: ChatMessageAttachmentModel.name, + schema: ChatMessageAttachmentSchema, + }, + { + name: ExpertPreparedMessageModel.name, + schema: ExpertPreparedMessageSchema, + }, + { name: StaffRoleModel.name, schema: StaffRoleSchema }, + ]), + forwardRef(() => UploadModule), + forwardRef(() => PoliciesModule), + ], + providers: [ + AdminService, + AuthService, + SamanSmsService, + SsoService, + RedisService, + AuditLogService, + PermissionsService, + ], + exports: [ + MongooseModule, + RedisService, + AdminService, + AuthService, + DatabaseModule, + HttpModule, + AuditLogService, + PermissionsService, + ], +}) +export class DatabaseModule implements OnModuleInit { + constructor( + @InjectModel(ClientModel.name) + private readonly clientModel: Model, + ) {} + async onModuleInit() { + const leanDocument = (await this.clientModel.find().lean().exec()).length; + // throw leanDocument == 0 ? new Error('Must Be Implements Client') : null + } + createClient(data: { name: string; email: string; company: string }) { + console.log(`Client created:`, data); + return { success: true, ...data }; + } +} diff --git a/src/database/model/admin.model.ts b/src/database/model/admin.model.ts new file mode 100644 index 0000000..8cff743 --- /dev/null +++ b/src/database/model/admin.model.ts @@ -0,0 +1,57 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; +import { RateModel } from './rate.model'; +import { UsersBaseModel } from './users-base.model'; + +export type AdminDocument = AdminModel & Document; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'admin', +}) +export class AdminModel extends UsersBaseModel { + @Prop({ type: Types.ObjectId, ref: 'FileUploadModel', required: false }) + avatar?: Types.ObjectId; + + @Prop() + password: string; + + @Prop({ default: [] }) + rate: RateModel[]; + + @Prop({ default: null }) + resetToken: string | null; + + @Prop({ default: true }) + isActive: Boolean; + + @Prop({ default: false }) + onlineStatus: Boolean; + + @Prop({ default: false }) + available: Boolean; + + @Prop({ default: 0 }) + activeSessions: number; + + @Prop({ default: 5 }) + maxSessions: number; + + /** Extra permissions beyond the role template (ADR 0001). */ + @Prop({ type: [String], default: [] }) + permissionGrants: string[]; + + /** Blocked permissions from the role template (ADR 0001). */ + @Prop({ type: [String], default: [] }) + permissionDenies: string[]; +} + +export const AdminSchema = SchemaFactory.createForClass(AdminModel); +AdminSchema.clearIndexes(); +AdminSchema.pre('save', function (next) { + this.updatedAt = new Date(); + // this.password = crypto.createHash('sha256').update(this.password).digest('hex'), + + next(); +}); diff --git a/src/database/model/audit-log.model.ts b/src/database/model/audit-log.model.ts new file mode 100644 index 0000000..14bd162 --- /dev/null +++ b/src/database/model/audit-log.model.ts @@ -0,0 +1,74 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: false }, + versionKey: false, + collection: 'audit_logs', +}) +export class AuditLogModel extends Document { + @Prop({ type: Types.ObjectId, ref: 'UserModel', required: false }) + userId?: Types.ObjectId; + + @Prop({ type: String, required: true }) + action: string; // e.g., 'joinRoom', 'sendMessage', 'expertOnline', etc. + + @Prop({ type: String, required: false }) + resource?: string; // e.g., 'Chat', 'Session', 'Expert', 'Message', etc. + + @Prop({ type: String, required: false }) + resourceId?: string; // e.g., sessionId, messageId, expertId, etc. + + @Prop({ type: Object, required: false }) + oldValues?: Record; // Previous state before change + + @Prop({ type: Object, required: false }) + newValues?: Record; // New state after change + + @Prop({ type: Object, required: false }) + changes?: Record; // Detailed changes + + @Prop({ type: String, required: false }) + ipAddress?: string; + + @Prop({ type: String, required: false }) + userAgent?: string; + + @Prop({ type: String, required: false }) + method?: string; // For WebSocket: event name, for HTTP: GET, POST, etc. + + @Prop({ type: String, required: false }) + endpoint?: string; // For WebSocket: roomId or path, for HTTP: route path + + @Prop({ type: Number, required: false }) + statusCode?: number; // HTTP status code or success indicator + + @Prop({ type: Object, required: false }) + metadata?: Record; // Additional context data + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; +} + +export const AuditLogSchema = SchemaFactory.createForClass(AuditLogModel); + +// Create index for common queries +AuditLogSchema.index({ userId: 1, createdAt: -1 }); +AuditLogSchema.index({ action: 1, createdAt: -1 }); +AuditLogSchema.index({ resource: 1, resourceId: 1 }); +AuditLogSchema.index({ createdAt: -1 }); + + + + + + + + + + + + + + + diff --git a/src/database/model/botReact.model.ts b/src/database/model/botReact.model.ts new file mode 100644 index 0000000..486c063 --- /dev/null +++ b/src/database/model/botReact.model.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; +import { ReactEnum } from 'src/common/types/react.type'; +import { Sender } from 'src/common/types/sender.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt' }, + collection: 'reacts', + versionKey: false, + _id: true, +}) + +export class ReactsModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + sessionId: Types.ObjectId; + + @Prop({ _id: true, default: new Types.ObjectId() }) + messageId: Types.ObjectId; + + @Prop({ _id: true, default: new Types.ObjectId() }) + userId: Types.ObjectId; + + @Prop({ default: false }) + question: string; + + @Prop({ default: false }) + answer: string; + + @Prop({ default: null, type: String, enum: Sender }) + sender: Sender; + + @Prop({ default: null, enum: ReactEnum }) + react: string; + + @Prop({ type: [String], required: true }) + createdAt: [string, string]; // [time, date] + + @Prop({ type: Date, default: Date.now }) + createdISO: Date; +} + +export const ReactSchema = SchemaFactory.createForClass(ReactsModel); diff --git a/src/database/model/business-hours.model.ts b/src/database/model/business-hours.model.ts new file mode 100644 index 0000000..36edc18 --- /dev/null +++ b/src/database/model/business-hours.model.ts @@ -0,0 +1,69 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export interface TimeInterval { + start: string; // HH:mm format + end: string; // HH:mm format +} + +export interface WeeklyWindow { + day: 'saturday' | 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday'; + intervals: TimeInterval[]; +} + +export interface Exception { + date: string; // YYYY-MM-DD format + intervals: TimeInterval[]; // Empty array means closed + reason?: string; +} + +export interface BusinessHoursConfig { + timezone: string; // IANA timezone (e.g., 'Asia/Tehran') + globalToggle: boolean; // Master switch + weeklyWindows: WeeklyWindow[]; + exceptions: Exception[]; + policy?: { + onClose: 'allow_existing_until_end' | 'hard_close'; + }; + messageTemplate?: { + key: string; + default: string; + }; +} + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'business_hours', + _id: true, +}) +export class BusinessHoursModel extends Document { + @Prop({ type: Object, required: true }) + config: BusinessHoursConfig; + + @Prop({ type: [String], default: [] }) + createdBy: string[]; // Array of admin usernames + + @Prop({ type: String, required: false }) + updatedBy?: string; // Last admin username who updated + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; + + @Prop({ type: Boolean, default: true }) + isActive: boolean; // Only one should be active at a time +} + +export const BusinessHoursSchema = SchemaFactory.createForClass(BusinessHoursModel); + + + + + + + + + diff --git a/src/database/model/categories.model.ts b/src/database/model/categories.model.ts new file mode 100644 index 0000000..88c723d --- /dev/null +++ b/src/database/model/categories.model.ts @@ -0,0 +1,29 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'categories', + _id: true, +}) +export class CategoriesModel { + @Prop({ type: String }) + title: string; + + @Prop({ type: String }) + enTitle: string; + + @Prop({ type: Number }) + priority: number; + + @Prop({ type: String }) + icon: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} + +export const CategoriesSchema = SchemaFactory.createForClass(CategoriesModel); diff --git a/src/database/model/chat-message-attachment.model.ts b/src/database/model/chat-message-attachment.model.ts new file mode 100644 index 0000000..d131471 --- /dev/null +++ b/src/database/model/chat-message-attachment.model.ts @@ -0,0 +1,52 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; +import type { ChatAttachmentFileType } from 'src/storage/storage.types'; + +@Schema({ + collection: 'chat_message_attachments', + timestamps: { createdAt: 'uploadedAt', updatedAt: false }, + versionKey: false, +}) +export class ChatMessageAttachmentModel extends Document { + @Prop({ type: Types.ObjectId, required: true }) + messageId: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: true }) + sessionId: Types.ObjectId; + + @Prop({ required: true }) + uploaderId: string; + + @Prop({ required: true, enum: ['User', 'Expert'] }) + uploaderRole: 'User' | 'Expert'; + + @Prop({ required: true, enum: ['voice', 'image', 'document'] }) + fileType: ChatAttachmentFileType; + + @Prop({ required: true }) + storageKey: string; + + @Prop({ required: true, default: 'private' }) + bucket: string; + + @Prop({ required: true }) + originalFilename: string; + + @Prop({ required: true }) + mimeType: string; + + @Prop({ required: true }) + size: number; + + @Prop({ type: Number, required: false }) + durationSec?: number; + + @Prop({ required: true, default: 'ready', enum: ['ready'] }) + status: 'ready'; +} + +export const ChatMessageAttachmentSchema = SchemaFactory.createForClass( + ChatMessageAttachmentModel, +); + +ChatMessageAttachmentSchema.index({ sessionId: 1, messageId: 1 }, { unique: true }); diff --git a/src/database/model/chat.model.ts b/src/database/model/chat.model.ts new file mode 100644 index 0000000..30cd6c3 --- /dev/null +++ b/src/database/model/chat.model.ts @@ -0,0 +1,39 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; +import { MessagesModel } from './messages.model'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, +}) +export class ChatModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + sessionId: Types.ObjectId; + + @Prop({ type: String }) + chatTitle: string; + + @Prop({ default: false }) + chatClosed: boolean; + + @Prop({ default: false }) + connectedToExpert: boolean; + + @Prop({ default: false }) + onlineChatClosed: boolean; + + @Prop({ default: null }) + expertRate: number | null; + + @Prop({ type: String }) + expert: string; + + @Prop({ type: [MessagesModel], default: [] }) + messages: MessagesModel[]; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} diff --git a/src/database/model/client.model.ts b/src/database/model/client.model.ts new file mode 100644 index 0000000..4b928fd --- /dev/null +++ b/src/database/model/client.model.ts @@ -0,0 +1,31 @@ +import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; +import { Document, HydratedDocument } from 'mongoose'; + +export interface ClientInterface { + name_fa: string; + + name_en: string; + + apiKey: string; + + status: string; +} + +export type ClientDocument = HydratedDocument; + +@Schema({ timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } }) +export class ClientModel extends Document { + @Prop({ required: true }) + name: string; + + @Prop({ required: true }) + enName: string; + + @Prop({ required: true, unique: true }) + apiKey: string; + + @Prop({ enum: ['active', 'inactive'], default: 'active' }) + status: string; +} + +export const ClientSchema = SchemaFactory.createForClass(ClientModel); diff --git a/src/database/model/dictionaries.model.ts b/src/database/model/dictionaries.model.ts new file mode 100644 index 0000000..7688d98 --- /dev/null +++ b/src/database/model/dictionaries.model.ts @@ -0,0 +1,77 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { QuestionsModel } from './questions.model'; + +export interface UpdatedBy { + name: string; // User's mobile number + updatedAt: { + time: string; // Time of update + date: string; // Date of update + }; +} + +@Schema({ + timestamps: false, + versionKey: false, + collection: 'dictionaries', + _id: true, +}) +export class DictionariesModel { + @Prop({ type: String }) + title: string; + + @Prop({ type: 'string' }) + category: string; + + @Prop({ type: String }) + type: 'Regulation' | 'Method'; + + @Prop() + questions: QuestionsModel[]; + + @Prop({ type: Boolean }) + isActive: boolean; + + @Prop({ type: [String] }) + fileId: string[]; + + @Prop({ type: String }) + icon: string; + + @Prop({ type: String, required: false }) + aiCollectionId: string; + + @Prop({ type: String, required: false }) + aiCollectionName: string; + + /** Cached from AI get-collections-with-descriptions / get-collection-description. */ + @Prop({ type: String, required: false }) + description?: string; + + @Prop({ type: [String], default: [] }) + keywords?: string[]; + + /** + * Sync bookkeeping vs AI SoT. + * ok | missing | deactivated | stale + */ + @Prop({ type: String, required: false, default: 'ok' }) + aiSyncStatus?: string; + + @Prop({ type: Date, required: false }) + lastSyncedAt?: Date; + + @Prop({ type: Array }) + createdAt: []; + + @Prop({ type: Date }) + createdISO: Date; + + @Prop({ type: Date }) + updatedAt: Date; + + @Prop({ type: [Object] }) + updatedBy: UpdatedBy[]; +} + +export const DictionariesSchema = + SchemaFactory.createForClass(DictionariesModel); diff --git a/src/database/model/dictionary-file-asset.model.ts b/src/database/model/dictionary-file-asset.model.ts new file mode 100644 index 0000000..91364a9 --- /dev/null +++ b/src/database/model/dictionary-file-asset.model.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +@Schema({ + collection: 'dictionary_file_assets', + timestamps: { createdAt: 'uploadedAt', updatedAt: false }, + versionKey: false, +}) +export class DictionaryFileAssetModel extends Document { + @Prop({ required: true }) + category: string; + + @Prop({ type: Types.ObjectId, ref: 'AdminModel', required: true }) + uploadedBy: Types.ObjectId; + + @Prop({ required: true }) + originalFilename: string; + + /** Key inside the private bucket (no bucket name). */ + @Prop({ required: true, unique: true }) + storageKey: string; + + @Prop({ required: true, default: 'private' }) + bucket: string; + + @Prop({ required: true }) + mimeType: string; + + @Prop({ required: true }) + size: number; +} + +export const DictionaryFileAssetSchema = + SchemaFactory.createForClass(DictionaryFileAssetModel); diff --git a/src/database/model/expert-prepared-message.model.ts b/src/database/model/expert-prepared-message.model.ts new file mode 100644 index 0000000..ea353b5 --- /dev/null +++ b/src/database/model/expert-prepared-message.model.ts @@ -0,0 +1,54 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'expert_prepared_messages', + _id: true, +}) +export class ExpertPreparedMessageModel extends Document { + @Prop({ type: String, required: true, trim: true }) + message: string; + + @Prop({ + type: String, + required: true, + enum: EXPERT_PREPARED_MESSAGE_CATEGORIES, + }) + category: ExpertPreparedMessageCategory; + + @Prop({ type: String, trim: true, default: '' }) + label: string; + + @Prop({ type: Boolean, default: true }) + isEnabled: boolean; + + @Prop({ type: Number, default: 0 }) + sortOrder: number; + + @Prop({ type: String, default: 'fa', trim: true }) + locale: string; + + @Prop({ type: String }) + createdBy?: string; + + @Prop({ type: String }) + updatedBy?: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} + +export const ExpertPreparedMessageSchema = SchemaFactory.createForClass( + ExpertPreparedMessageModel, +); + +ExpertPreparedMessageSchema.index({ isEnabled: 1, category: 1, sortOrder: 1 }); diff --git a/src/database/model/externalTokens.model.ts b/src/database/model/externalTokens.model.ts new file mode 100644 index 0000000..d9e2845 --- /dev/null +++ b/src/database/model/externalTokens.model.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'externalTokens', + _id: true, +}) +export class ExternalTokensModel { + @Prop({ type: String }) + token: string; + + @Prop({ type: 'string' }) + url: string; + + @Prop({ type: Boolean }) + isActive: boolean; + + @Prop({ type: String }) + method: 'access' | 'refresh'; + + @Prop({ type: String }) + tokenType: 'Bearer' | 'Basic'; + + @Prop({ type: String }) + expiresIn: string; + + @Prop({ type: String }) + scope: string; + + @Prop({ type: Number }) + timestamps: Number; + + @Prop({ type: Array }) + createdAt: []; + + @Prop({ type: Array }) + updatedAt: []; +} + +export const ExternalTokenSchema = + SchemaFactory.createForClass(ExternalTokensModel); diff --git a/src/database/model/fileUpload.model.ts b/src/database/model/fileUpload.model.ts new file mode 100644 index 0000000..968b779 --- /dev/null +++ b/src/database/model/fileUpload.model.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type FileUploadDocument = FileUploadModel & Document; +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'file', + _id: true, +}) +export class FileUploadModel { + @Prop({ required: true }) + originalName: string; + + @Prop({ required: true }) + filename: string; + + @Prop({ required: true }) + filePath: string; + + @Prop({ required: true }) + mimetype: string; + + @Prop({ required: true }) + size: number; + + @Prop({ required: true }) + type: string; // e.g., 'image', 'document' + + @Prop({ required: true }) + category: string; // e.g., 'profile', 'invoice' +} + +export const FileUploadSchema = SchemaFactory.createForClass(FileUploadModel); diff --git a/src/database/model/installment.model.ts b/src/database/model/installment.model.ts new file mode 100644 index 0000000..713d4df --- /dev/null +++ b/src/database/model/installment.model.ts @@ -0,0 +1,91 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'installments', + _id: true, +}) +export class InstallmentModel { + @Prop({ type: Number, required: true, index: true }) + policyId: number; + + @Prop({ type: Types.ObjectId, required: true, index: true }) + userId: Types.ObjectId; + + @Prop({ type: String, required: true, unique: true }) + installmentId: string; + + @Prop({ type: String, required: false }) + paymentMethod: string; + + @Prop({ type: String, required: false }) + installmentYear: string; + + @Prop({ type: Number, required: false }) + installmentNumber: number; + + @Prop({ type: Number, required: false }) + installmentPaymentAmount: number; + + @Prop({ type: Number, required: false }) + remainingAmount: number; + + @Prop({ type: String, required: false }) + installmentDate: string; + + @Prop({ type: String, required: false }) + installmentPaymentDate: string; + + @Prop({ type: String, required: false }) + installmentStatus: string; + + @Prop({ type: Number, required: false }) + totalInstallments: number; + + @Prop({ type: Number, required: false }) + paidInstallments: number; + + @Prop({ type: Number, required: false }) + upcomingInstallmentCount: number; + + @Prop({ type: Number, required: false }) + overdueInstallmentCount: number; + + @Prop({ type: String, required: false }) + bankId: string; + + @Prop({ type: Number, required: false }) + totalPremiumCollected: number; + + @Prop({ type: Boolean, default: true, index: true }) + isActive: boolean; + + @Prop({ type: String, required: false, index: true }) + syncBatchId: string; + + @Prop({ type: Date, required: false }) + lastSyncedAt: Date; + + @Prop({ type: Date, default: Date.now }) + lastUpdated: Date; + + @Prop({ type: Date }) + createdAt: Date; + + @Prop({ type: Date }) + updatedAt: Date; +} + +export const InstallmentSchema = SchemaFactory.createForClass(InstallmentModel); + +// Create compound indexes for efficient queries +InstallmentSchema.index({ policyId: 1, userId: 1 }); +InstallmentSchema.index({ userId: 1 }); +InstallmentSchema.index({ policyId: 1, installmentStatus: 1 }); +InstallmentSchema.index({ installmentId: 1 }, { unique: true }); +InstallmentSchema.index({ userId: 1, isActive: 1 }); +InstallmentSchema.index({ userId: 1, syncBatchId: 1 }); + +// Made with Bob diff --git a/src/database/model/messages.model.ts b/src/database/model/messages.model.ts new file mode 100644 index 0000000..330e58c --- /dev/null +++ b/src/database/model/messages.model.ts @@ -0,0 +1,26 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; +import { ReactEnum } from 'src/common/types/react.type'; +import { Sender } from 'src/common/types/sender.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt' }, + versionKey: false, + _id: true, +}) +export class MessagesModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + messageId: Types.ObjectId; + + @Prop({ default: false }) + text: string; + + @Prop({ default: null, type: Sender }) + sender: Sender; + + @Prop({ default: null, enum: ReactEnum }) + react: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; +} diff --git a/src/database/model/policy.model.ts b/src/database/model/policy.model.ts new file mode 100644 index 0000000..8c5a431 --- /dev/null +++ b/src/database/model/policy.model.ts @@ -0,0 +1,105 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'policies', + _id: true, +}) +export class PolicyModel { + @Prop({ type: Types.ObjectId, required: true, index: true }) + userId: Types.ObjectId; + + @Prop({ type: Number, required: true, index: true }) + policyId: number; + + @Prop({ type: String, required: true }) + policyNumber: string; + + @Prop({ type: String, required: true }) + insuranceLineName: string; + + @Prop({ type: Number, required: true }) + insuranceLineCode: number; + + @Prop({ type: String, required: true }) + policyHolderName: string; + + @Prop({ type: String, required: true }) + insuredName: string; + + @Prop({ type: String, required: true }) + policyIssueDate: string; + + @Prop({ type: String, required: true }) + policyBeginDate: string; + + @Prop({ type: String, required: true }) + policyEndDate: string; + + @Prop({ type: String, required: true }) + policyStatus: string; + + @Prop({ type: Number, required: true }) + policyStatusCode: number; + + @Prop({ type: String, required: true }) + uniquePolicyCode: string; + + @Prop({ type: String, required: false }) + referrerName: string; + + @Prop({ type: String, required: false }) + issuingUnit: string; + + @Prop({ type: String, required: false }) + policyHolderCode: string; + + @Prop({ type: Number, required: false }) + agentCode: number; + + @Prop({ type: String, required: true }) + policyType: string; // 'life', 'car', 'health', 'fire', 'cargo', 'equipment', 'travel', 'liability', 'personalAccident' + + @Prop({ type: String, required: false }) + address: string; + + @Prop({ type: String, required: false }) + licensePlate: string; + + @Prop({ type: String, required: false }) + vehicleType: string; + + @Prop({ type: Object, required: false }) + additionalData: Record; + + @Prop({ type: Boolean, default: true, index: true }) + isActive: boolean; + + @Prop({ type: String, required: false, index: true }) + syncBatchId: string; + + @Prop({ type: Date, required: false }) + lastSyncedAt: Date; + + @Prop({ type: Date, default: Date.now }) + lastUpdated: Date; + + @Prop({ type: Date }) + createdAt: Date; + + @Prop({ type: Date }) + updatedAt: Date; +} + +export const PolicySchema = SchemaFactory.createForClass(PolicyModel); + +// Create compound index for efficient queries +PolicySchema.index({ userId: 1, policyId: 1 }, { unique: true }); +PolicySchema.index({ policyId: 1 }); +PolicySchema.index({ userId: 1, policyStatusCode: 1 }); +PolicySchema.index({ userId: 1, isActive: 1 }); +PolicySchema.index({ userId: 1, syncBatchId: 1 }); + +// Made with Bob diff --git a/src/database/model/questionModification.model.ts b/src/database/model/questionModification.model.ts new file mode 100644 index 0000000..06095c7 --- /dev/null +++ b/src/database/model/questionModification.model.ts @@ -0,0 +1,56 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +export interface requestedBy { + name: string; // User's mobile number + _id: Types.ObjectId; +} + +@Schema({ collection: 'questionModification' }) +export class QuestionModificationModel { + @Prop({ type: Types.ObjectId, ref: 'DictionariesModel' }) + dictionaryId: Types.ObjectId; + + @Prop({ type: Types.ObjectId }) + questionId: Types.ObjectId; + + @Prop({ type: String }) + oldQuestion: string; + + @Prop({ type: String }) + oldAnswer: string; + + @Prop({ type: String }) + newQuestion: string; + + @Prop({ type: String }) + newAnswer: string; + + @Prop({ type: Types.ObjectId }) + requestedBy: requestedBy; // expert's mobile or ID + + @Prop({ type: String }) + status: 'pending' | 'approved' | 'rejected'; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Array }) + createdAtPersian: []; + + @Prop({ type: Date }) + reviewedAt?: Date; + + @Prop({ type: Array }) + reviewedAtPersian: []; + + @Prop({ type: Types.ObjectId }) + reviewedBy?: requestedBy; // admin's ID + + @Prop({ type: String }) + rejectionReason?: string; +} + +export const QuestionModificationSchema = SchemaFactory.createForClass( + QuestionModificationModel, +); diff --git a/src/database/model/questions.model.ts b/src/database/model/questions.model.ts new file mode 100644 index 0000000..cd955e7 --- /dev/null +++ b/src/database/model/questions.model.ts @@ -0,0 +1,44 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +export interface UpdatedBy { + name: string; // User's mobile number + updatedAt: { + time: string; // Time of update + date: string; // Date of update + }; +} +@Schema({ + timestamps: false, + versionKey: false, + _id: true, +}) +export class QuestionsModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + _id: Types.ObjectId; + + @Prop({ type: 'string' }) + question: string; + + @Prop({ type: 'string' }) + answer: string; + + /** AI sync/export item id (UUID). Required for AI-first PUT/DELETE. */ + @Prop({ type: String, required: false }) + aiItemId?: string; + + @Prop({ type: Boolean, default: false }) + deleted: boolean; + + @Prop({ type: Array }) + createdAt: any[]; + + @Prop({ type: Number }) + createdISO: number; + + @Prop({ type: Array }) + updatedAt: any[]; + + @Prop({ type: [Object] }) + updatedBy: UpdatedBy[]; +} diff --git a/src/database/model/rate.model.ts b/src/database/model/rate.model.ts new file mode 100644 index 0000000..fcd6267 --- /dev/null +++ b/src/database/model/rate.model.ts @@ -0,0 +1,20 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, +}) +export class RateModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + sessionId: Types.ObjectId; + + @Prop({ type: String }) + rate: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + createdAtDate: Date; +} diff --git a/src/database/model/reassigned-logs.model.ts b/src/database/model/reassigned-logs.model.ts new file mode 100644 index 0000000..8f07106 --- /dev/null +++ b/src/database/model/reassigned-logs.model.ts @@ -0,0 +1,33 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'reassigned_logs', +}) +export class ReassignedLogsModel extends Document { + @Prop({ type: Types.ObjectId, ref: 'SessionModel', required: true }) + sessionId: Types.ObjectId; + + @Prop({ type: Types.ObjectId, ref: 'UserModel', required: true }) + userId: Types.ObjectId; + + @Prop({ type: String, required: true }) + from: string; // Expert email (from expert) + + @Prop({ type: String, required: false, default: null }) + to: string | null; // Expert email (to expert), or null if no expert available + + @Prop({ type: String, required: false, default: 'auto' }) + source: 'manual' | 'auto'; + + @Prop({ type: String, required: false }) + mode?: 'selective' | 'random'; + + @Prop({ type: String, required: false }) + reason?: string; +} + +export const ReassignedLogsSchema = SchemaFactory.createForClass(ReassignedLogsModel); + diff --git a/src/database/model/sessions.model.ts b/src/database/model/sessions.model.ts new file mode 100644 index 0000000..723a34b --- /dev/null +++ b/src/database/model/sessions.model.ts @@ -0,0 +1,125 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; +import { Sender } from 'src/common/types/sender.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'sessions', // Name of the new collection +}) +export class SessionModel extends Document { + @Prop({ type: Types.ObjectId, ref: 'UserModel', required: true }) + userId: Types.ObjectId; // Reference to the user who owns this session + + @Prop({ required: true }) + chatTitle: string; + + @Prop({ required: true, default: false }) + connectedToExpert: boolean; + + /** AI offered a human handoff; user still chooses whether to open online chat. */ + @Prop({ required: false, default: false }) + escalationOffered: boolean; + + @Prop({ required: true, default: false }) + onlineChatClosed: boolean; + + @Prop({ type: Date, required: false, default: null }) + onlineStartDate: Date; + + @Prop({ type: Date, required: false, default: null }) + onlineEndDate: Date; + + @Prop({ required: true, default: false }) + chatClosed: boolean; + + @Prop({ required: false }) + expert: string; + + /** Manual expert transfers per session (max 1). Auto-reassign does not increment this. */ + @Prop({ required: true, default: 0 }) + transferCount: number; + + @Prop({ required: false }) + expertRate: number; + + @Prop({ required: false }) + adminRateToExpert: boolean; + + @Prop({ required: false }) + adminRateToBot: boolean; + + @Prop({ required: false }) + roomId: string; + + @Prop({ type: [String], required: true }) + createdAt: [string, string]; // [time, date] + + @Prop({ type: Date, default: Date.now }) + createdISO: Date; + + @Prop({ + type: [ + { + messageId: { type: Types.ObjectId, required: true }, + text: { type: String, required: true }, + sender: { type: String, required: true }, + react: { type: String, default: 'Nothing' }, + runId: { type: String, required: false }, + aiStatus: { type: String, required: false }, + escalation: { type: Object, required: false }, + createdAt: { type: [String], required: true }, // [time, date] + createdISO: { type: Date, required: true }, + edited: { type: Boolean, default: false }, + messageType: { + type: String, + enum: ['text', 'image', 'voice', 'document'], + default: 'text', + }, + voiceDurationSec: { type: Number, required: false }, + voiceMimeType: { type: String, required: false }, + /** Image/document or generic attachment MIME (voice still uses voiceMimeType for compatibility). */ + mimeType: { type: String, required: false }, + replyTo: { + type: { + messageId: { type: Types.ObjectId, required: true }, + textPreview: { type: String, default: '' }, + sender: { type: String, required: true }, + createdISO: { type: Date, required: false }, + unavailable: { type: Boolean, default: false }, + }, + required: false, + }, + }, + ], + default: [], + }) + messages: { + messageId: Types.ObjectId; + text: string; + sender: Sender; + react: string; + createdAt: [string, string]; + createdISO: Date; + edited: boolean; + runId?: string; + aiStatus?: string; + escalation?: { + summary?: string; + handoff_context?: Record; + }; + messageType?: 'text' | 'image' | 'voice' | 'document'; + voiceDurationSec?: number; + voiceMimeType?: string; + mimeType?: string; + replyTo?: { + messageId: Types.ObjectId; + textPreview: string; + sender: Sender; + createdISO?: Date; + unavailable: boolean; + }; + }[]; +} + +export const SessionSchema = SchemaFactory.createForClass(SessionModel); diff --git a/src/database/model/staff-role.model.ts b/src/database/model/staff-role.model.ts new file mode 100644 index 0000000..9b899a2 --- /dev/null +++ b/src/database/model/staff-role.model.ts @@ -0,0 +1,33 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type StaffRoleDocument = StaffRoleModel & Document; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'staff_roles', +}) +export class StaffRoleModel { + /** Unique role slug stored on admin.role (e.g. admin, expert, content_manager). */ + @Prop({ required: true, unique: true, index: true }) + name: string; + + @Prop({ required: false }) + displayName?: string; + + @Prop({ type: [String], default: [] }) + permissions: string[]; + + /** System roles cannot be deleted or renamed. */ + @Prop({ default: false }) + isSystem: boolean; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} + +export const StaffRoleSchema = SchemaFactory.createForClass(StaffRoleModel); diff --git a/src/database/model/user-insurance-snapshot.model.ts b/src/database/model/user-insurance-snapshot.model.ts new file mode 100644 index 0000000..fb6e0b2 --- /dev/null +++ b/src/database/model/user-insurance-snapshot.model.ts @@ -0,0 +1,35 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'user_insurance_snapshots', + _id: true, +}) +export class UserInsuranceSnapshotModel { + @Prop({ type: Types.ObjectId, required: true, unique: true, index: true }) + userId: Types.ObjectId; + + @Prop({ type: String, required: true }) + syncBatchId: string; + + @Prop({ type: Date, required: true }) + syncedAt: Date; + + @Prop({ type: Object, required: true }) + user_insurance_data: Record; + + @Prop({ type: Object, required: true }) + user_installments_data: Record; + + @Prop({ type: Date }) + createdAt: Date; + + @Prop({ type: Date }) + updatedAt: Date; +} + +export const UserInsuranceSnapshotSchema = SchemaFactory.createForClass( + UserInsuranceSnapshotModel, +); diff --git a/src/database/model/user.model.ts b/src/database/model/user.model.ts new file mode 100644 index 0000000..31f9ea5 --- /dev/null +++ b/src/database/model/user.model.ts @@ -0,0 +1,37 @@ +import * as crypto from 'node:crypto'; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { ChatModel } from './chat.model'; +import { UsersBaseModel } from './users-base.model'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'user', + _id: true, +}) +export class UserModel extends UsersBaseModel { + @Prop({ default: [] }) + chat: ChatModel[]; + + @Prop({ + required: false, + select: true, + set: (otp: string) => + otp ? crypto.createHash('sha256').update(otp).digest('hex') : null, + expires: 120, // The OTP will automatically be removed after 120 seconds + }) + otp: string | null; + + @Prop({ type: Date, default: null }) + otpCreatedAt: Date | null; + + @Prop({ type: Number, default: 0 }) + otpAttempts: number; +} + +export const UserSchema = SchemaFactory.createForClass(UserModel); + +UserSchema.pre('save', function (next) { + this.updatedAt = new Date(); + next(); +}); diff --git a/src/database/model/users-base.model.ts b/src/database/model/users-base.model.ts new file mode 100644 index 0000000..27d3636 --- /dev/null +++ b/src/database/model/users-base.model.ts @@ -0,0 +1,76 @@ +import * as crypto from 'node:crypto'; +import { Prop, Schema } from '@nestjs/mongoose'; +import { Role } from 'src/common/types/role.type'; + +@Schema() +export class UsersBaseModel { + @Prop() + name: string; + + @Prop() + family: string; + + @Prop({ required: true }) + role: Role; + + @Prop() + fatherName: string; + + @Prop() + shenasnameseri: string; + + @Prop() + shenasnameserial: string; + + @Prop() + birthDate: string; + + @Prop() + gender: string; + + @Prop({ + required: false, + match: /^[0-9]{11}$/, + default: undefined, + }) + mobile: string; + + @Prop() + birthday: string; + + @Prop() + nationalCode: string; + + @Prop({ match: /^((?!\.)[\w\-_.]*[^.])(@\w+)(\.\w+(\.\w+)?[^.\W])$/ }) + email: string; + + @Prop() + address: string; + + @Prop({ + default: function () { + // Generate 6-digit number or use mobile + return ( + this.mobile || Math.floor(100000 + Math.random() * 900000).toString() + ); + }, + }) + username: string; + + @Prop({ type: Date }) + last_login: Date; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; + + static validateOtp(otpInput: string, storedOtpHash: string): boolean { + const inputOtpHash = crypto + .createHash('sha256') + .update(otpInput) + .digest('hex'); + return inputOtpHash === storedOtpHash; + } +} diff --git a/src/dictionaries/dictionaries.controller.ts b/src/dictionaries/dictionaries.controller.ts new file mode 100644 index 0000000..55786bf --- /dev/null +++ b/src/dictionaries/dictionaries.controller.ts @@ -0,0 +1,415 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + HttpStatus, +} from '@nestjs/common'; +import { + ApiOperation, + ApiBody, + ApiDefaultResponse, + ApiBearerAuth, + ApiParam, + ApiTags, + ApiQuery, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { AdminModel } from 'src/database/model/admin.model'; +import { DictionariesService } from './dictionaries.service'; +import { DictionaryAiSyncService } from './dictionary-ai-sync.service'; +import { + CreateDictionaryDto, + CreateQuestionDto, + ModificationRequestDto, +} from './dto/create-dictionary.dto'; +import { + UpdateDictionaryDto, + UpdateQuestionDto, + QuestionDto, +} from './dto/update-dictionary.dto'; +import { + CreateDictionaryFromAiDto, + LinkAiCollectionDto, +} from './dto/ai-sync.dto'; +import { ReactEnum } from 'src/common/types/react.type'; + +@ApiBearerAuth() +@Permissions(Permission.DictionariesRead) +@UseGuards(AdminGuard) +@ApiTags('dictionary & question') +@Controller('dictionaries') +export class DictionariesController { + constructor( + private readonly dictionariesService: DictionariesService, + private readonly aiSync: DictionaryAiSyncService, + ) {} + + @ApiBody({ type: CreateDictionaryDto }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Permissions(Permission.DictionariesCreate) + @Post() + createDictionary(@Body() createDictionaryDto: CreateDictionaryDto) { + return this.dictionariesService.createDictionary(createDictionaryDto); + } + + @Get('/list') + @ApiOperation({ + summary: + 'Get all dictionaries without questions (for listing) - filterable by date and category', + }) + @ApiQuery({ + name: 'category', + required: false, + type: String, + description: 'Filter by dictionary category', + example: 'fireInsurance', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + findAllDictionariesList( + @Query('category') category?: string, + @Query('date') dateRange?: string, + ) { + let startDate: string | undefined; + let endDate: string | undefined; + + if (dateRange) { + [startDate, endDate] = dateRange.split('-'); + } + + return this.dictionariesService.findAllDictionariesList({ + category, + startDate, + endDate, + }); + } + + @Get('/ai/unlinked') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: + 'List AI collections not linked to any Mongo dictionary (staff decides link/create)', + }) + async listUnlinkedAiCollections() { + const data = await this.aiSync.listUnlinkedAiCollections(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/sync') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: 'Manually reconcile all linked dictionaries from AI (SoT)', + }) + async syncAllFromAi() { + const data = await this.aiSync.syncAllLinked(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/sync/:collectionName') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: 'Manually reconcile one linked collection from AI (SoT)', + }) + @ApiParam({ name: 'collectionName' }) + async syncOneFromAi(@Param('collectionName') collectionName: string) { + const data = await this.aiSync.syncOneByCollectionName(collectionName); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/link') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: 'Link an existing Mongo dictionary to an AI collection and pull Q&A', + }) + @ApiBody({ type: LinkAiCollectionDto }) + async linkAiCollection(@Body() body: LinkAiCollectionDto) { + const data = await this.aiSync.linkDictionaryToCollection(body); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/create-from-collection') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ + summary: + 'Create a Mongo dictionary shell from an unlinked AI collection and pull Q&A', + }) + @ApiBody({ type: CreateDictionaryFromAiDto }) + async createFromAiCollection(@Body() body: CreateDictionaryFromAiDto) { + const data = await this.aiSync.createDictionaryFromAiCollection(body); + return new BaseResponseDTO(HttpStatus.CREATED, 'SUCCESS', data); + } + + @Get('/:dictionaryId/questions') + @ApiOperation({ + summary: + 'Get all questions for a specific dictionary - filterable by date and category', + }) + @ApiParam({ + name: 'dictionaryId', + description: 'The ID of the dictionary', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter questions by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'category', + required: false, + type: String, + description: 'Filter by dictionary category (must match the dictionary)', + example: 'fireInsurance', + }) + findDictionaryQuestions( + @Param('dictionaryId') dictionaryId: string, + @Query('date') dateRange?: string, + @Query('category') category?: string, + ) { + let startDate: string | undefined; + let endDate: string | undefined; + + if (dateRange) { + [startDate, endDate] = dateRange.split('-'); + } + + return this.dictionariesService.findDictionaryQuestions(dictionaryId, { + startDate, + endDate, + category, + }); + } + + @ApiOperation({ + summary: 'Get single dictionary details with id.', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @ApiParam({ name: 'dictionaryId' }) + @Get(':dictionaryId') + findOne(@Param('dictionaryId') dictionaryId?: string) { + return this.dictionariesService.findOne(dictionaryId); + } + + @ApiOperation({ + summary: 'Toggle active or deactive a dictionary', + }) + @ApiParam({ name: 'dictionaryId' }) + @Permissions(Permission.DictionariesWrite) + @Patch('/:dictionaryId/toggle-active') + async toggleActive(@Param('dictionaryId') dictionaryId?: string) { + return this.dictionariesService.toggleActive(dictionaryId); + } + + @ApiOperation({ + summary: 'Admin can modify some attributes of the uploaded dictionary.', + }) + @ApiParam({ name: 'dictionaryId' }) + @Permissions(Permission.DictionariesWrite) + @Patch(':dictionaryId') + update( + @Param('dictionaryId') dictionaryId: string, + @Body() updateDictionaryDto: UpdateDictionaryDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.updateDictionary( + dictionaryId, + updateDictionaryDto, + adminIdentity, + ); + } + + @ApiOperation({ + summary: + 'Admin gets all the questions in the system (excluding soft-deleted)', + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'Search in questions and answers', + example: 'بیمه مسافرتی', + }) + @Get('questions/List') + findAllQuestions(@Query('search') search?: string) { + return this.dictionariesService.findAllQuestions(search); + } + + @ApiOperation({ + summary: 'Admin can modify some attributes of the extracted questions', + }) + @ApiParam({ name: 'questionId' }) + @Permissions(Permission.DictionariesWrite) + @Patch('/:questionId/modify') + updateQuestion( + @Param('questionId') questionId: string, + @Body() updateQuestionDto: UpdateQuestionDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.updateQuestion( + questionId, + updateQuestionDto, + adminIdentity, + ); + } + + @ApiOperation({ + summary: 'Admin can update all questions within a dictionary', + }) + @ApiParam({ name: 'dictionaryId' }) + @ApiBody({ type: [QuestionDto] }) + @Permissions(Permission.DictionariesDirectEdit) + @Patch('/:dictionaryId/questions/update-all') + updateAllQuestions( + @Param('dictionaryId') dictionaryId: string, + @Body() updatedQuestions: QuestionDto[], + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.updateAllQuestions( + dictionaryId, + updatedQuestions, + adminIdentity, + ); + } + + @Permissions(Permission.DictionariesApprove) + @ApiOperation({ + summary: 'get the list of modification request | admin', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @ApiQuery({ + name: 'status', + required: false, + enum: ['approved', 'pending', 'rejected'], + }) + @Get('/get/modification-request') + getModificationRequests( + @Query('status') status?: 'approved' | 'pending' | 'rejected', + ) { + return this.dictionariesService.getAllModificationRequests(status); + } + + @Permissions(Permission.DictionariesApprove) + @ApiOperation({ + summary: 'approve or reject the modification request | admin', + }) + @ApiBody({ type: ModificationRequestDto }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Post('/post/modification-request') + approveModificationRequest( + @Body() modificationRequest: ModificationRequestDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.modificationRequestApprove( + modificationRequest, + adminIdentity, + ); + } + + @ApiOperation({ + summary: 'Admin create a question in a dictionary', + }) + @Permissions(Permission.DictionariesWrite) + @Post('/question/create') + createQuestion( + @Body() createQuestionDto: CreateQuestionDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.createQuestion( + createQuestionDto, + adminIdentity, + ); + } + + @ApiOperation({ + summary: 'Admin delete the extracted question', + }) + @ApiParam({ name: 'questionId' }) + @Permissions(Permission.DictionariesWrite) + @Delete('/:questionId/delete') + deleteQuestion( + @Param('questionId') questionId: string, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.deleteQuestion(questionId, adminIdentity); + } + + @Permissions(Permission.DictionariesApprove) + @ApiOperation({ + summary: 'get the list of bot answers which has reacts , LIKE or DISLIKE', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @ApiQuery({ + name: 'status', + required: false, + type: String, + description: 'status of the reaction , LIKE or DISLIKE or ALL', + example: 'ALL', + enum: ['LIKE', 'DISLIKE', 'ALL'], + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31"). If not provided, returns all reacts.', + example: '1404/01/01-1404/01/31', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (starts from 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of items per page', + example: 50, + }) + @Get('/get/answer-reacts') + getReactedAnswers( + @Query('status') status?: ReactEnum, + @Query('date') dateRange?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? parseInt(limit, 10) : 50; + + return this.dictionariesService.getAnswerReacts( + status, + dateRange, + pageNum, + limitNum, + ); + } +} diff --git a/src/dictionaries/dictionaries.module.ts b/src/dictionaries/dictionaries.module.ts new file mode 100644 index 0000000..4a9fb8d --- /dev/null +++ b/src/dictionaries/dictionaries.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { AiServiceModule } from 'src/ai-service/ai-service.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { StorageModule } from 'src/storage/storage.module'; +import { DictionariesController } from './dictionaries.controller'; +import { DictionaryFileAssetsController } from './dictionary-file-assets.controller'; +import { DictionaryFileAssetsService } from './dictionary-file-assets.service'; +import { DictionariesService } from './dictionaries.service'; +import { DictionaryAiSyncService } from './dictionary-ai-sync.service'; + +@Module({ + imports: [DatabaseModule, AiServiceModule, StorageModule], + controllers: [DictionariesController, DictionaryFileAssetsController], + providers: [ + DictionariesService, + DictionaryFileAssetsService, + DictionaryAiSyncService, + ], + exports: [DictionaryAiSyncService], +}) +export class DictionariesModule {} diff --git a/src/dictionaries/dictionaries.service.ts b/src/dictionaries/dictionaries.service.ts new file mode 100644 index 0000000..2dc9332 --- /dev/null +++ b/src/dictionaries/dictionaries.service.ts @@ -0,0 +1,1536 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import * as csv from 'fast-csv'; +import { Model, Types } from 'mongoose'; +import { PermissionsService } from 'src/acl/permissions.service'; +import { AiServiceService } from 'src/ai-service/ai-service.service'; +import { BaseResponseDTO, PageOptionsDto, PageMetaDto } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { DictionariesModel } from 'src/database/model/dictionaries.model'; +import { FileUploadModel } from 'src/database/model/fileUpload.model'; +import { QuestionModificationModel } from 'src/database/model/questionModification.model'; +import { CategoriesModel } from 'src/database/model/categories.model'; // Added CategoriesModel import +import { ReactsModel } from 'src/database/model/botReact.model'; // Added ReactsModel import +import { UserModel } from 'src/database/model/user.model'; +import { + CreateDictionaryDto, + CreateQuestionDto, + ModificationRequestDto, +} from './dto/create-dictionary.dto'; +import { + UpdateDictionaryDto, + UpdateQuestionDto, + QuestionDto, +} from './dto/update-dictionary.dto'; +import { Sender } from 'src/common/types/sender.type'; +import { ReactEnum } from 'src/common/types/react.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { DictionaryAiSyncService } from './dictionary-ai-sync.service'; + +@Injectable() +export class DictionariesService { + constructor( + @InjectModel(FileUploadModel.name) + private readonly fileModel: Model, + @InjectModel(DictionariesModel.name) + private readonly dictionariesModel: Model, + @InjectModel(QuestionModificationModel.name) + private readonly questionModification: Model, + @InjectModel(CategoriesModel.name) // Injected CategoriesModel + private readonly categoriesModel: Model, + @InjectModel(ReactsModel.name) + private readonly reactsModel: Model, + @InjectModel(UserModel.name) + private readonly userModel: Model, + private readonly aiService: AiServiceService, + private readonly permissionsService: PermissionsService, + private readonly aiSync: DictionaryAiSyncService, + ) {} + + private getCurrentJalaliTime(): { time: string; date: string } { + const now = Date.now() / 1000; + const [time, date] = TimeHelper.unix2PersianTimeAndDate(now); + return { time, date }; + } + + // async createDictionary(body: CreateDictionaryDto) { + // const now = Date.now() / 1000; + // const results = []; + // let aiCollectionIdForCategory: string = null; + // let existingDictionaryForCategory; + + // try { + // const categoryDoc = await this.categoriesModel.findOne({ _id: body.category }); + // if (!categoryDoc) { + // throw new HttpException( + // `Category with ID ${body.category} not found.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // const categoryEnTitle = categoryDoc.enTitle; + + // // Check if a collection for this category already exists + // existingDictionaryForCategory = await this.dictionariesModel.findOne({ + // category: body.category, + // isActive: true, + // }); + + // // Check AI collections + // const aiCollections = await this.aiService.getCollections(); + // const aiCollectionNames = JSON.parse(aiCollections.replace(/'/g, '"')).map(name => name.toLowerCase()); + + // switch (body.func) { + // case 'create': + // console.log(`[createDictionary] Operation: CREATE for category: ${body.category}`); + // if (existingDictionaryForCategory) { + // throw new HttpException( + // `A collection already exists for category ${body.category}. Use 'update' or 'replace' function.`, + // HttpStatus.CONFLICT, + // ); + // } + // if (aiCollectionNames.includes(categoryEnTitle.toLowerCase())) { + // throw new HttpException( + // `AI collection with name ${categoryEnTitle} already exists. Use 'update' or 'replace' function.`, + // HttpStatus.CONFLICT, + // ); + // } + + // for (let i = 0; i < body.fileIds.length; i++) { + // const fileId = body.fileIds[i]; + // console.log(`[createDictionary][CREATE] Processing file with ID: ${fileId}`); + // const file = await this.fileModel.findOne({ _id: fileId }); + // if (!file) + // throw new HttpException( + // `file_not_found for ID: ${fileId}`, + // HttpStatus.NOT_FOUND, + // ); + + // console.log(`[createDictionary][CREATE] Original filename: ${file.originalName}`); + // const dynamicFileName = `${categoryEnTitle}${path.extname( + // file.originalName, + // )}`; + // console.log(`[createDictionary][CREATE] Dynamic filename: ${dynamicFileName}`); + + // // AI file upload + // let aiResponse; + // if (file.type === 'txt' || file.type === 'text/plain') { + // console.log('[createDictionary][CREATE] Calling AI aiUploadTxt...'); + // aiResponse = await this.aiService.aiUploadTxt(file); + // } else if (file.type === 'csv' || file.type === 'text/csv') { + // console.log('[createDictionary][CREATE] Calling AI aiUploadCsv...'); + // aiResponse = await this.aiService.aiUploadCsv(file); + // } + // const filenameMain = aiResponse.file_path.split('/').pop(); + // console.log(filenameMain) + // if (i === 0) { + // // First file: create new collection + // console.log('[createDictionary][CREATE] Calling AI service for new collection...'); + // await this.aiService.newCollection( + // categoryEnTitle, + // filenameMain, + // body.description, + // ); + // aiCollectionIdForCategory = categoryEnTitle; // Set AI collection ID to category enTitle + // console.log( + // `[createDictionary][CREATE] New AI collection ID ${aiCollectionIdForCategory} and name ${categoryEnTitle} saved for category ${body.category}`, + // ); + // } else { + // // Subsequent files: update existing collection + // console.log( + // `[createDictionary][CREATE] Calling AI service to update collection ${aiCollectionIdForCategory}...`, + // ); + // await this.aiService.updateCollection( + // aiCollectionIdForCategory, + // dynamicFileName, + // categoryEnTitle, + // ); + // } + + // console.log('[createDictionary][CREATE] AI collection operation successful.'); + + // const fullPath = path.join(process.cwd(), file.filePath); + + // console.log('[createDictionary][CREATE] Parsing CSV file...'); + // const parsedFile = await this.parseCsvFile(fullPath); + // const parsedFileWithDeleted = parsedFile.map((item) => ({ + // ...item, + // deleted: false, + // })); + // const filteredQuestions = parsedFileWithDeleted.filter( + // (item) => !item.deleted, + // ); + // const questionsArr = filteredQuestions.map((item) => ({ + // ...item, + // _id: new Types.ObjectId(), + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + // updatedBy: [], + // })); + + // const dic = await this.dictionariesModel.findOneAndUpdate( + // { category: body.category }, + // { + // title: categoryEnTitle, // Use categoryEnTitle as title + // category: body.category, + // type: body.type, + // isActive: true, + // $addToSet: { fileId: fileId }, // Add fileId to array + // icon: body.icon, + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedBy: [], + // questions: questionsArr, + // aiCollectionId: aiCollectionIdForCategory, + // aiCollectionName: categoryEnTitle, + // }, + // { upsert: true, new: true, setDefaultsOnInsert: true }, + // ); + // results.push({ + // fileId: fileId, + // status: 'SUCCESS', + // dictionary: dic, + // }); + // } + // break; + + // case 'update': + // console.log(`[createDictionary] Operation: UPDATE for category: ${body.category}`); + // if (!existingDictionaryForCategory || !existingDictionaryForCategory.aiCollectionId) { + // throw new HttpException( + // `No existing collection found for category ${body.category}. Use 'create' function.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) { + // throw new HttpException( + // `AI collection with name ${categoryEnTitle} does not exist. Use 'create' function.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // aiCollectionIdForCategory = categoryEnTitle; // Set to categoryEnTitle + + // for (let i = 0; i < body.fileIds.length; i++) { + // const fileId = body.fileIds[i]; + // console.log(`[createDictionary][UPDATE] Processing file with ID: ${fileId}`); + // const file = await this.fileModel.findOne({ _id: fileId }); + // if (!file) + // throw new HttpException( + // `file_not_found for ID: ${fileId}`, + // HttpStatus.NOT_FOUND, + // ); + + // console.log(`[createDictionary][UPDATE] Original filename: ${file.originalName}`); + // const dynamicFileName = `${categoryEnTitle}${path.extname( + // file.originalName, + // )}`; + // console.log(`[createDictionary][UPDATE] Dynamic filename: ${dynamicFileName}`); + + // // AI file upload + // if (file.type === 'txt' || file.type === 'text/plain') { + // console.log('[createDictionary][UPDATE] Calling AI aiUploadTxt...'); + // await this.aiService.aiUploadTxt(file); + // } else if (file.type === 'csv' || file.type === 'text/csv') { + // console.log('[createDictionary][UPDATE] Calling AI aiUploadCsv...'); + // await this.aiService.aiUploadCsv(file); + // } + + // console.log( + // `[createDictionary][UPDATE] Calling AI service to update collection ${aiCollectionIdForCategory}...`, + // ); + // await this.aiService.updateCollection( + // aiCollectionIdForCategory, + // dynamicFileName, + // categoryEnTitle, + // ); + + // console.log('[createDictionary][UPDATE] AI collection operation successful.'); + + // const fullPath = path.join(process.cwd(), file.filePath); + + // console.log('[createDictionary][UPDATE] Parsing CSV file...'); + // const parsedFile = await this.parseCsvFile(fullPath); + // const parsedFileWithDeleted = parsedFile.map((item) => ({ + // ...item, + // deleted: false, + // })); + // const filteredQuestions = parsedFileWithDeleted.filter( + // (item) => !item.deleted, + // ); + // const questionsArr = filteredQuestions.map((item) => ({ + // ...item, + // _id: new Types.ObjectId(), + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + // updatedBy: [], + // })); + + // const dic = await this.dictionariesModel.findOneAndUpdate( + // { category: body.category }, + // { + // $addToSet: { fileId: fileId }, // Add fileId to array + // $set: { + // title: categoryEnTitle, // Use categoryEnTitle as title + // type: body.type, + // isActive: true, + // icon: body.icon, + // updatedBy: [], // This might need to be dynamic or pushed + // aiCollectionId: aiCollectionIdForCategory, + // aiCollectionName: categoryEnTitle, + // }, + // $push: { questions: { $each: questionsArr } }, // Push new questions + // }, + // { new: true }, + // ); + // results.push({ + // fileId: fileId, + // status: 'SUCCESS', + // dictionary: dic, + // }); + // } + // break; + + // case 'replace': + // console.log(`[createDictionary] Operation: REPLACE for category: ${body.category}`); + // if (!existingDictionaryForCategory || !existingDictionaryForCategory.aiCollectionId) { + // throw new HttpException( + // `No existing collection found for category ${body.category}. Cannot perform replace.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) { + // throw new HttpException( + // `AI collection with name ${categoryEnTitle} does not exist. Cannot perform replace.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // if (body.fileIds.length !== 1) { + // throw new HttpException( + // 'Replace operation expects exactly one file to be uploaded.', + // HttpStatus.BAD_REQUEST, + // ); + // } + + // aiCollectionIdForCategory = categoryEnTitle; // Set to categoryEnTitle + + // // Deactivate existing collection in AI service + // console.log(`[createDictionary][REPLACE] Deactivating AI collection: ${aiCollectionIdForCategory}`); + // await this.aiService.deactivateCollection(aiCollectionIdForCategory); + + // const fileIdToReplace = body.fileIds[0]; + // console.log(`[createDictionary][REPLACE] Processing new file with ID: ${fileIdToReplace} for replacement`); + // const newFile = await this.fileModel.findOne({ _id: fileIdToReplace }); + // if (!newFile) + // throw new HttpException( + // `file_not_found for ID: ${fileIdToReplace}`, + // HttpStatus.NOT_FOUND, + // ); + + // const dynamicFileNameForReplace = `${categoryEnTitle}${path.extname(newFile.originalName)}`; + // console.log(`[createDictionary][REPLACE] Dynamic filename for AI service: ${dynamicFileNameForReplace}`); + + // // AI file upload for the new file + // if (newFile.type === 'txt' || newFile.type === 'text/plain') { + // console.log('[createDictionary][REPLACE] Calling AI aiUploadTxt for new file...'); + // await this.aiService.aiUploadTxt(newFile); + // } else if (newFile.type === 'csv' || newFile.type === 'text/csv') { + // console.log('[createDictionary][REPLACE] Calling AI aiUploadCsv for new file...'); + // await this.aiService.aiUploadCsv(newFile); + // } + + // console.log('[createDictionary][REPLACE] Calling AI service for new collection (after deactivation)...'); + // await this.aiService.newCollection( + // categoryEnTitle, + // dynamicFileNameForReplace, + // body.description, + // ); + + // console.log('[createDictionary][REPLACE] AI collection operation successful.'); + + // const fullPathReplace = path.join(process.cwd(), newFile.filePath); + + // console.log('[createDictionary][REPLACE] Parsing CSV file...'); + // const parsedFileReplace = await this.parseCsvFile(fullPathReplace); + // const parsedFileWithDeletedReplace = parsedFileReplace.map((item) => ({ + // ...item, + // deleted: false, + // })); + // const filteredQuestionsReplace = parsedFileWithDeletedReplace.filter( + // (item) => !item.deleted, + // ); + // const questionsArrReplace = filteredQuestionsReplace.map((item) => ({ + // ...item, + // _id: new Types.ObjectId(), + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + // updatedBy: [], + // })); + + // // Update the existing dictionary entry with new data + // const dicReplace = await this.dictionariesModel.findOneAndUpdate( + // { category: body.category }, + // { + // $set: { + // title: categoryEnTitle, // Use categoryEnTitle as title + // type: body.type, + // isActive: true, + // fileId: [fileIdToReplace], // Replace fileId with new one + // icon: body.icon, + // updatedBy: [], // This might need to be dynamic or pushed + // questions: questionsArrReplace, // Replace questions with new ones + // aiCollectionId: aiCollectionIdForCategory, + // aiCollectionName: categoryEnTitle, + // }, + // }, + // { new: true }, + // ); + // results.push({ + // fileId: fileIdToReplace, + // status: 'SUCCESS', + // dictionary: dicReplace, + // }); + // break; + + // default: + // throw new HttpException('Invalid func provided', HttpStatus.BAD_REQUEST); + // } + // return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + // } catch (err) { + // console.log(err); + // throw new BaseResponseDTO( + // err.status || HttpStatus.INTERNAL_SERVER_ERROR, + // err.response || err.message, + // null, + // ); + // } + // } + + async createDictionary(body: CreateDictionaryDto) { + try { + const nowUnix = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(nowUnix); + + const { fileIds, func, category, description, icon, type } = body; + + if (!Array.isArray(fileIds) || fileIds.length === 0) + throw new HttpException('file_ids_required', HttpStatus.BAD_REQUEST); + if (!func || !category || !description || !icon || !type) + throw new HttpException('invalid_payload', HttpStatus.BAD_REQUEST); + + // Resolve category doc and english title + let categoryDoc = await this.categoriesModel.findOne({ _id: category }); + if (!categoryDoc) { + // Fallback: allow passing enTitle directly + categoryDoc = await this.categoriesModel.findOne({ enTitle: category }); + } + if (!categoryDoc) + throw new HttpException( + `category_not_found`, + HttpStatus.NOT_FOUND, + ); + const categoryEnTitle = categoryDoc.enTitle; + + // Fetch current AI collections + const aiCollectionNames = (await this.aiSync.listAiCollectionNames()).map((n) => + n.toLowerCase(), + ); + + // Find existing dictionary for category (active) + const existingDictionary = await this.dictionariesModel.findOne({ + category: categoryDoc._id?.toString() || category, + isActive: true, + }); + + const results: any[] = []; + + const uploadAndMaybeParse = async (fileId: string) => { + const file = await this.fileModel.findOne({ _id: fileId }); + if (!file) + throw new HttpException(`file_not_found: ${fileId}`, HttpStatus.NOT_FOUND); + + // Upload to AI service based on mimetype/extension + const ext = path.extname(file.originalName || '').toLowerCase(); + const isCsv = file.mimetype?.includes('csv') || ext === '.csv'; + const isTxt = file.mimetype?.includes('text/plain') || ext === '.txt'; + + if (isTxt) { + await this.aiService.aiUploadTxt(file); + } else if (isCsv) { + await this.aiService.aiUploadCsv(file); + } else { + throw new HttpException('unsupported_file_type', HttpStatus.BAD_REQUEST); + } + + // Local CSV parse is intentionally unused — AI is SoT; cache filled via sync/export. + return { file, isCsv, isTxt }; + }; + + const refreshCacheFromAi = async (dictionaryId: string) => { + await this.aiSync.pullCollectionIntoDictionary( + dictionaryId, + categoryEnTitle, + { description, keywords: [] }, + ); + return this.dictionariesModel.findById(dictionaryId); + }; + + switch ((func || '').toLowerCase()) { + case 'create': { + // Must NOT exist in AI and NOT exist in dictionaries; category must exist + if (existingDictionary) + throw new HttpException( + `dictionary_already_exists_for_category`, + HttpStatus.CONFLICT, + ); + if (aiCollectionNames.includes(categoryEnTitle.toLowerCase())) + throw new HttpException( + `ai_collection_already_exists`, + HttpStatus.CONFLICT, + ); + + for (let i = 0; i < fileIds.length; i++) { + const { file } = await uploadAndMaybeParse(fileIds[i]); + + // Create collection for first file, update for the rest + if (i === 0) { + await this.aiService.newCollection( + categoryEnTitle, + file.originalName, + description, + ); + } else { + await this.aiService.updateCollection( + categoryEnTitle, + file.originalName, + description, + ); + } + + const dic = await this.dictionariesModel.findOneAndUpdate( + { category: categoryDoc._id?.toString() || category }, + { + title: categoryEnTitle, + category: categoryDoc._id?.toString() || category, + type: type, + isActive: true, + $addToSet: { fileId: file._id.toString() }, + icon: icon, + description, + keywords: [], + createdAt: nowPersian, + createdISO: new Date(nowUnix * 1000), + updatedAt: new Date(), + updatedBy: [], + aiCollectionId: categoryEnTitle, + aiCollectionName: categoryEnTitle, + aiSyncStatus: 'stale', + questions: [], + }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ); + + const refreshed = await refreshCacheFromAi(String(dic._id)); + results.push({ + fileId: file._id.toString(), + status: 'SUCCESS', + dictionary: refreshed, + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + } + case 'update': { + // Must exist in AI and in dictionaries + if (!existingDictionary) + throw new HttpException(`dictionary_not_found`, HttpStatus.NOT_FOUND); + if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) + throw new HttpException(`ai_collection_not_found`, HttpStatus.NOT_FOUND); + + for (let i = 0; i < fileIds.length; i++) { + const { file } = await uploadAndMaybeParse(fileIds[i]); + + await this.aiService.updateCollection( + categoryEnTitle, + file.originalName, + description, + ); + + const dic = await this.dictionariesModel.findOneAndUpdate( + { category: categoryDoc._id?.toString() || category }, + { + $addToSet: { fileId: file._id.toString() }, + $set: { + title: categoryEnTitle, + type: type, + isActive: true, + icon: icon, + description, + updatedAt: new Date(), + aiCollectionId: categoryEnTitle, + aiCollectionName: categoryEnTitle, + aiSyncStatus: 'stale', + }, + }, + { new: true }, + ); + const refreshed = await refreshCacheFromAi(String(dic._id)); + results.push({ + fileId: file._id.toString(), + status: 'SUCCESS', + dictionary: refreshed, + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + } + case 'replace': { + // Must exist in AI and in dictionaries + if (!existingDictionary) + throw new HttpException(`dictionary_not_found`, HttpStatus.NOT_FOUND); + if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) + throw new HttpException(`ai_collection_not_found`, HttpStatus.NOT_FOUND); + + // If multiple files: replace by first, then update by the rest + const firstUpload = await uploadAndMaybeParse(fileIds[0]); + + await this.aiService.replaceCollection( + categoryEnTitle, + firstUpload.file.originalName, + description, + ); + + for (let i = 1; i < fileIds.length; i++) { + const up = await uploadAndMaybeParse(fileIds[i]); + await this.aiService.updateCollection( + categoryEnTitle, + up.file.originalName, + description, + ); + } + + const dicReplace = await this.dictionariesModel.findOneAndUpdate( + { category: categoryDoc._id?.toString() || category }, + { + $set: { + title: categoryEnTitle, + type: type, + isActive: true, + fileId: fileIds.map((id) => id.toString()), + icon: icon, + description, + updatedAt: new Date(), + updatedBy: [], + aiCollectionId: categoryEnTitle, + aiCollectionName: categoryEnTitle, + aiSyncStatus: 'stale', + questions: [], + }, + }, + { new: true }, + ); + + const refreshed = await refreshCacheFromAi(String(dicReplace._id)); + results.push({ + fileId: fileIds[0], + status: 'SUCCESS', + dictionary: refreshed, + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + } + default: + throw new HttpException('invalid_func', HttpStatus.BAD_REQUEST); + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + async createQuestion(createQuestionDto: CreateQuestionDto, adminIdentity: any) { + try { + const { question, answer, dictionaryId } = createQuestionDto; + + if (!question || !answer || !dictionaryId) + throw new HttpException('invalid_payload', HttpStatus.BAD_REQUEST); + + await this.aiSync.ensureFreshIfStale(dictionaryId); + + const dictionary = await this.dictionariesModel.findById(dictionaryId); + if (!dictionary) + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + if (!dictionary.isActive) + throw new HttpException('dictionary_not_active', HttpStatus.FORBIDDEN); + if (!dictionary.aiCollectionId || !dictionary.aiCollectionName) + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + + const collectionName = dictionary.aiCollectionName || dictionary.aiCollectionId; + const aiResponse = await this.aiService.createExportItem(collectionName, { + q: question, + a: answer, + }); + + let aiItemId = this.aiSync.extractAiItemIdFromCreateResponse(aiResponse); + if (!aiItemId) { + // AI may not return id — full pull is the SoT refresh + await this.aiSync.pullCollectionIntoDictionary(dictionaryId, collectionName); + const refreshed = await this.dictionariesModel.findById(dictionaryId); + const created = refreshed?.questions + ?.filter((q: any) => !q.deleted) + ?.find( + (q: any) => q.question === question && q.answer === answer, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', created ?? refreshed); + } + + const adminLabel = adminIdentity?.userData?.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family ?? ''}`.trim() + : adminIdentity?.userData?.mobile ?? 'admin'; + + try { + const created = await this.aiSync.applyCreateToCache({ + dictionaryId, + question, + answer, + aiItemId, + adminLabel, + }); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', created); + } catch (cacheErr) { + await this.aiSync.markStale(dictionaryId); + throw cacheErr; + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private parseCsvFile( + filePath: string, + ): Promise<{ question: string; answer: string }[]> { + return new Promise((resolve, reject) => { + const results: { question: string; answer: string }[] = []; + + fs.createReadStream(filePath) + .pipe(csv.parse({ headers: true })) + .on('data', (row) => { + results.push({ + question: row.q, + answer: row.a, + }); + }) + .on('end', () => resolve(results)) + .on('error', (err) => reject(err)); + }); + } + + /** + * Bulk replace via AI collection replace, then full sync/export into Mongo cache. + * Client payload is sent to AI as CSV; Mongo questions come only from export. + */ + private async syncDictionaryToAi(dictionaryId: string): Promise { + let tempCsvPath: string | null = null; + try { + const dictionary = await this.dictionariesModel.findById(dictionaryId); + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + if (!dictionary.aiCollectionId || !dictionary.aiCollectionName) { + console.warn( + `[syncDictionaryToAi] Dictionary ${dictionaryId} has no aiCollectionId/aiCollectionName, skipping AI sync`, + ); + return; + } + + const rows = dictionary.questions + .filter((q: any) => !q.deleted) + .map((q: any) => ({ q: q.question, a: q.answer })); + + if (rows.length === 0) { + console.warn( + `[syncDictionaryToAi] Dictionary ${dictionaryId} has no non-deleted questions, skipping AI sync`, + ); + return; + } + + const csvFileName = `${dictionary.aiCollectionName}_${Date.now()}.csv`; + tempCsvPath = path.join(process.cwd(), 'uploads', csvFileName); + + const uploadsDir = path.join(process.cwd(), 'uploads'); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(tempCsvPath); + csv + .write(rows, { headers: true }) + .pipe(writeStream) + .on('error', reject) + .on('finish', resolve); + }); + + const csvFile = { + filePath: tempCsvPath, + originalName: csvFileName, + mimetype: 'text/csv', + }; + + await this.aiService.aiUploadCsv(csvFile); + await this.aiService.replaceCollection( + dictionary.aiCollectionId, + csvFileName, + dictionary.title || dictionary.aiCollectionName, + ); + + await this.aiSync.pullCollectionIntoDictionary( + dictionaryId, + dictionary.aiCollectionName || dictionary.aiCollectionId, + ); + } finally { + if (tempCsvPath && fs.existsSync(tempCsvPath)) { + try { + fs.unlinkSync(tempCsvPath); + } catch (cleanupErr) { + console.error(`[syncDictionaryToAi] Failed to cleanup temp CSV: ${tempCsvPath}`, cleanupErr); + } + } + } + } + + async findAllDictionariesList(filters: { + category?: string; + startDate?: string; + endDate?: string; + }) { + try { + const query: any = {}; + + if (filters.category) { + query.category = filters.category; + } + if (filters.startDate && filters.endDate) { + const startISO = TimeHelper.jalaliToISO(filters.startDate); + const endISO = TimeHelper.jalaliToISO(filters.endDate); + + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // Use aggregation to get dictionaries with question count efficiently + const aggregationPipeline: any[] = [ + { $match: query }, + { + $project: { + _id: 1, + title: 1, + category: 1, + type: 1, + isActive: 1, + fileId: 1, + icon: 1, + aiCollectionId: 1, + aiCollectionName: 1, + createdAt: 1, + createdISO: { $toLong: '$createdISO' }, // Convert Date to timestamp + updatedAt: 1, + updatedBy: 1, + questionCount: { + $size: { + $filter: { + input: '$questions', + as: 'question', + cond: { $eq: ['$$question.deleted', false] }, + }, + }, + }, + }, + }, + ]; + + const dictionaries = await this.dictionariesModel + .aggregate(aggregationPipeline) + .exec(); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', dictionaries); + } catch (err) { + console.error('Error in findAllDictionariesList:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + async findDictionaryQuestions( + dictionaryId: string, + filters: { + startDate?: string; + endDate?: string; + category?: string; + }, + ) { + try { + await this.aiSync.ensureFreshIfStale(dictionaryId); + + // First, find the dictionary + const dictionary = await this.dictionariesModel.findById(dictionaryId); + + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + + // If category filter is provided, verify it matches the dictionary + if (filters.category && dictionary.category !== filters.category) { + throw new HttpException( + 'Category filter does not match the dictionary category', + HttpStatus.BAD_REQUEST, + ); + } + + // Filter questions + let filteredQuestions = dictionary.questions.filter( + (question) => !question.deleted, + ); + + // Apply date filter if provided + if (filters.startDate && filters.endDate) { + const startISO = TimeHelper.jalaliToISO(filters.startDate); + const endISO = TimeHelper.jalaliToISO(filters.endDate); + + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const startTimestamp = startDateObj.getTime(); + const endTimestamp = endDateObj.getTime(); + + filteredQuestions = filteredQuestions.filter((question) => { + // createdISO is a number (timestamp) + const questionTimestamp = question.createdISO; + return ( + questionTimestamp >= startTimestamp && + questionTimestamp <= endTimestamp + ); + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + dictionaryId: dictionary._id, + dictionaryTitle: dictionary.title, + dictionaryCategory: dictionary.category, + questions: filteredQuestions, + questionCount: filteredQuestions.length, + }); + } catch (err) { + console.error('Error in findDictionaryQuestions:', err); + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + async findOne(dictionaryId) { + try { + await this.aiSync.ensureFreshIfStale(dictionaryId); + const dictionary = await this.dictionariesModel.findOne({ + _id: dictionaryId, + }); + if (!dictionary) + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + const filteredQuestions = dictionary.questions.filter( + (question) => !question.deleted, + ); + + const filteredDictionary = { + ...dictionary.toObject(), + questions: filteredQuestions, + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', filteredDictionary); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async toggleActive(id: string): Promise { + try { + const dictionary = await this.dictionariesModel.findById(id).exec(); + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + dictionary.isActive = !dictionary.isActive; + await dictionary.save(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', dictionary); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async updateDictionary( + dictionaryId: any, + updateDictionaryDto: UpdateDictionaryDto, + adminIdentity, + ) { + const now = Date.now() / 1000; + try { + const dictionary = await this.dictionariesModel.findOneAndUpdate( + { _id: dictionaryId }, + { + ...updateDictionaryDto, + updatedBy: { + name: adminIdentity.userData.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family}` + : adminIdentity.userData.mobile, + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), // todo push to updatedBy Array + }, + }, + { new: true }, + ); + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', dictionary); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findAllQuestions(search?: string) { + try { + const query: any = { + isActive: true, + questions: { $exists: true, $not: { $size: 0 } }, + }; + + // Add search filter if provided + if (search?.trim()) { + const searchRegex = new RegExp(search.trim(), 'i'); + query.$or = [ + { 'questions.question': searchRegex }, + { 'questions.answer': searchRegex }, + ]; + } + + const activeDictionaries = await this.dictionariesModel.find(query); + + const structuredData = activeDictionaries.map((dictionary) => { + // Filter out deleted questions + let questions = dictionary.questions.filter((question) => !question.deleted); + + // If search is provided, filter questions that match the search + if (search?.trim()) { + const searchRegex = new RegExp(search.trim(), 'i'); + questions = questions.filter( + (question) => + searchRegex.test(question.question) || + searchRegex.test(question.answer), + ); + } + + return { + dicTitle: dictionary.title, + _id: dictionary._id, + category: dictionary.category, + isActive: dictionary.isActive, + questions, + }; + }).filter((dictionary) => dictionary.questions.length > 0); // Remove dictionaries with no matching questions + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', structuredData); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async updateQuestion( + questionId, + updateQuestionDto: UpdateQuestionDto, + adminIdentity, + ) { + const now = Date.now() / 1000; + try { + if (!updateQuestionDto.question || !updateQuestionDto.answer) { + throw new HttpException( + 'question_answer_required', + HttpStatus.BAD_REQUEST, + ); + } + + const canDirectEdit = await this.permissionsService.hasPermission( + String(adminIdentity.userData._id), + Permission.DictionariesDirectEdit, + ); + + if (canDirectEdit) { + const updatedQuestion = await this.updateQuestionDirectly( + questionId, + updateQuestionDto, + adminIdentity, + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', updatedQuestion); + } else { + // Without direct-edit permission (e.g. expert) — create modification request + return await this.createModificationRequest( + questionId, + updateQuestionDto, + adminIdentity, + ); + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async updateAllQuestions( + dictionaryId: string, + updatedQuestions: QuestionDto[], + adminIdentity, + ) { + try { + if (!updatedQuestions || updatedQuestions.length === 0) { + throw new HttpException( + 'questions_required', + HttpStatus.BAD_REQUEST, + ); + } + + for (const q of updatedQuestions) { + if (!q.question || !q.answer) { + throw new HttpException( + 'question_answer_required', + HttpStatus.BAD_REQUEST, + ); + } + } + + const dictionary = await this.dictionariesModel.findById(dictionaryId); + + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + if (!dictionary.aiCollectionId || !dictionary.aiCollectionName) { + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + } + + const canDirectEdit = await this.permissionsService.hasPermission( + String(adminIdentity.userData._id), + Permission.DictionariesDirectEdit, + ); + if (!canDirectEdit) { + throw new HttpException( + 'unauthorized_access', + HttpStatus.FORBIDDEN, + ); + } + + // Stage desired live set in Mongo temporarily, push to AI via replace, then re-pull SoT. + const now = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(now); + const nowPersianObj = this.getCurrentJalaliTime(); + const adminInfo = { + name: adminIdentity.userData.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family ?? ''}`.trim() + : adminIdentity.userData.mobile, + _id: adminIdentity.userData._id, + updatedAt: nowPersianObj, + }; + + const questionsToSave = updatedQuestions.map((q) => { + const questionId = q._id || new Types.ObjectId(); + return { + ...q, + _id: questionId, + updatedAt: nowPersian, + updatedBy: q.updatedBy ? [...q.updatedBy, adminInfo] : [adminInfo], + createdAt: q.createdAt || nowPersian, + createdISO: q.createdISO || now * 1000, + deleted: false, + }; + }); + + await this.dictionariesModel.findOneAndUpdate( + { _id: dictionaryId }, + { $set: { questions: questionsToSave, aiSyncStatus: 'stale' } }, + { new: true }, + ); + + try { + await this.syncDictionaryToAi(dictionaryId); + } catch (err) { + await this.aiSync.markStale(dictionaryId); + throw err; + } + + const refreshed = await this.dictionariesModel.findById(dictionaryId); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', refreshed); + } catch (err) { + console.error(err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + private async createModificationRequest( + questionId, + updateQuestionDto, + expertIdentity, + ) { + // Find the original question + const now = Date.now() / 1000; + + const isPending = await this.questionModification.findOne({ + questionId: new Types.ObjectId(questionId), + status: 'pending', + }); + if (isPending) + throw new HttpException( + 'request_pending_modify', + HttpStatus.NOT_ACCEPTABLE, + ); + + const dictionary = await this.dictionariesModel.findOne({ + 'questions._id': new Types.ObjectId(questionId), + 'questions.deleted': false, + }); + + if (!dictionary) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + + const question = dictionary.questions.find( + (q) => q._id.toString() === questionId, + ); + + // Create modification request + const request = await new this.questionModification({ + dictionaryId: dictionary._id, + questionId: new Types.ObjectId(questionId), + oldQuestion: question.question, + oldAnswer: question.answer, + newQuestion: updateQuestionDto.question, + newAnswer: updateQuestionDto.answer, + requestedBy: { + name: expertIdentity.userData.name + ? `${expertIdentity.userData.name} ${expertIdentity.userData.family}` + : expertIdentity.userData.mobile, + _id: expertIdentity.userData._id, + }, + status: 'pending', + createdAtPersian: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: now * 1000, + }); + + await request.save(); + + return new BaseResponseDTO(HttpStatus.OK, 'MODIFICATION_REQUEST_CREATED', { + message: 'request_submitted_for_approval', + requestId: request._id, + }); + } + + async getAnswerReacts( + status?: ReactEnum | string, + dateRange?: string, + page?: number, + limit?: number, + ) { + try { + // Build query based on status filter + const query: any = {}; + + if (status && status !== 'ALL') { + // Filter by specific status (LIKE or DISLIKE) + // Use case-insensitive regex to match variations like "like", "Like", "LIKE" + query.react = new RegExp(`^${status}$`, 'i'); + } + // If status is 'ALL' or undefined, return all reacts + + // Add date filter if provided + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // Pagination setup + const pageNum = page || 1; + const limitNum = limit || 50; + const skip = (pageNum - 1) * limitNum; + + // Get total count for pagination metadata + const totalCount = await this.reactsModel.countDocuments(query).exec(); + + // Fetch reacts with pagination, sorted by creation date (newest first) + const reacts = await this.reactsModel + .find(query) + .sort({ createdISO: -1 }) // Sort by creation date descending (newest first) + .skip(skip) + .limit(limitNum) + .lean() + .exec(); + + // Get unique user IDs to fetch user data in batch + const userIds = [...new Set(reacts.map((react) => String(react.userId)))]; + const users = await this.userModel + .find({ _id: { $in: userIds.map((id) => new Types.ObjectId(id)) } }) + .select('_id mobile') + .lean() + .exec(); + + // Create a map for quick lookup + const userMap = new Map( + users.map((user) => [String(user._id), user.mobile || null]) + ); + + const formattedReacts = reacts.map((react) => ({ + _id: react._id, + sessionId: react.sessionId, + messageId: react.messageId, + userId: react.userId, + userPhoneNumber: userMap.get(String(react.userId)) || null, + question: react.question, + answer: react.answer, + sender: react.sender, + react: typeof react.react === 'string' ? react.react.toLowerCase() : react.react, + createdAt: react.createdAt, + createdISO: react.createdISO, + })); + + // Create pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: pageNum, + take: limitNum, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: totalCount }); + + return new BaseResponseDTO(HttpStatus.OK, 'REACTED_ANSWERS', formattedReacts, meta); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async getAllModificationRequests(status?: 'approved' | 'pending' | 'rejected') { + try { + const query: any = {}; + if (status) { + query.status = status; + } + const requests = await this.questionModification.find(query); + const structuredData = requests.map((request) => ({ + requestId: request._id, + dictionaryId: request.dictionaryId, + questionId: request.questionId, + status: request.status, + oldQuestion: request.oldQuestion, + oldAnswer: request.oldAnswer, + newQuestion: request.newQuestion, + newAnswer: request.newAnswer, + requestedBy: request.requestedBy, + reviewedBy: request.reviewedBy ? request.reviewedBy : null, + reviewedAtPersian: request.reviewedAtPersian + ? request.reviewedAtPersian + : null, + reviewedAt: request.reviewedAt ? request.reviewedAt : null, + dateTime: request.createdAtPersian, + createdISO: request.createdAt, + })); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', structuredData); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private async updateQuestionDirectly( + questionId: string, + updateData: { question: string; answer: string }, + userIdentity: any, + ) { + const dictionary = await this.dictionariesModel.findOne({ + 'questions._id': new Types.ObjectId(questionId), + 'questions.deleted': false, + }); + if (!dictionary) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + + await this.aiSync.ensureFreshIfStale(String(dictionary._id)); + + const fresh = await this.dictionariesModel.findById(dictionary._id); + const questionDoc = fresh?.questions?.find( + (q) => q._id.toString() === questionId && !q.deleted, + ); + if (!questionDoc) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + if (!questionDoc.aiItemId) { + throw new HttpException('ai_item_id_required_sync_first', HttpStatus.CONFLICT); + } + if (!fresh.aiCollectionName && !fresh.aiCollectionId) { + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + } + + const collectionName = fresh.aiCollectionName || fresh.aiCollectionId; + await this.aiService.updateExportItem(collectionName, questionDoc.aiItemId, { + q: updateData.question, + a: updateData.answer, + }); + + const adminLabel = userIdentity.userData.name + ? `${userIdentity.userData.name} ${userIdentity.userData.family ?? ''}`.trim() + : userIdentity.userData.mobile; + + try { + const updatedQuestion = await this.aiSync.applyUpdateToCache({ + dictionaryId: String(fresh._id), + questionId, + question: updateData.question, + answer: updateData.answer, + adminLabel, + }); + return { updatedQuestion, success: true }; + } catch (err) { + await this.aiSync.markStale(String(fresh._id)); + throw err; + } + } + + async modificationRequestApprove( + modificationRequestDto: ModificationRequestDto, + adminIdentity, + ) { + try { + const now = Date.now() / 1000; + const modificationRequest = await this.questionModification.findOne({ + _id: modificationRequestDto.requestId, + status: 'pending', + }); + if (!modificationRequest) + throw new HttpException('request_not_found', HttpStatus.NOT_FOUND); + + if (modificationRequestDto.approved == true) { + await this.updateQuestionDirectly( + modificationRequest.questionId.toString(), + { + question: modificationRequest.newQuestion, + answer: modificationRequest.newAnswer, + }, + adminIdentity, + ); + } + + await this.questionModification.updateOne( + { + _id: modificationRequestDto.requestId, + }, + { + status: + modificationRequestDto.approved == true ? 'approved' : 'reject', + reviewedBy: { + name: adminIdentity.userData.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family}` + : adminIdentity.userData.mobile, + _id: adminIdentity.userData._id, + }, + reviewedAtPersian: TimeHelper.unix2PersianTimeAndDate(now), + reviewedAt: Date.now(), + }, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', 'updated'); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async deleteQuestion(questionId, adminIdentity) { + try { + const dictionary = await this.dictionariesModel.findOne({ + 'questions._id': new Types.ObjectId(questionId), + 'questions.deleted': false, + }); + if (!dictionary) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + + await this.aiSync.ensureFreshIfStale(String(dictionary._id)); + + const fresh = await this.dictionariesModel.findById(dictionary._id); + const questionDoc = fresh?.questions?.find( + (q) => q._id.toString() === questionId && !q.deleted, + ); + if (!questionDoc) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + if (!questionDoc.aiItemId) { + throw new HttpException('ai_item_id_required_sync_first', HttpStatus.CONFLICT); + } + if (!fresh.aiCollectionName && !fresh.aiCollectionId) { + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + } + + const collectionName = fresh.aiCollectionName || fresh.aiCollectionId; + await this.aiService.deleteExportItem(collectionName, questionDoc.aiItemId); + + try { + const deleted = await this.aiSync.applyDeleteToCache({ + dictionaryId: String(fresh._id), + questionId, + }); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', deleted); + } catch (err) { + await this.aiSync.markStale(String(fresh._id)); + throw err; + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } +} diff --git a/src/dictionaries/dictionary-ai-sync.service.ts b/src/dictionaries/dictionary-ai-sync.service.ts new file mode 100644 index 0000000..30cdca4 --- /dev/null +++ b/src/dictionaries/dictionary-ai-sync.service.ts @@ -0,0 +1,660 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { AiServiceService } from 'src/ai-service/ai-service.service'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { DictionariesModel } from 'src/database/model/dictionaries.model'; + +export type AiSyncStatus = 'ok' | 'missing' | 'deactivated' | 'stale'; + +const EXPORT_PAGE_SIZE = 50; + +@Injectable() +export class DictionaryAiSyncService { + private readonly logger = new Logger(DictionaryAiSyncService.name); + private readonly syncing = new Set(); + + constructor( + @InjectModel(DictionariesModel.name) + private readonly dictionariesModel: Model, + private readonly aiService: AiServiceService, + ) {} + + /** Hourly reconciliation of all linked dictionaries. */ + @Cron(CronExpression.EVERY_HOUR) + async hourlyReconcile() { + this.logger.log('Starting hourly AI dictionary reconciliation'); + try { + await this.syncAllLinked(); + } catch (err) { + this.logger.error('Hourly AI dictionary reconciliation failed', err as Error); + } + } + + coerceCollectionNames(val: unknown): string[] { + try { + if (Array.isArray(val)) return val.map((v) => String(v)); + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val.replace(/'/g, '"')); + if (Array.isArray(parsed)) return parsed.map((v) => String(v)); + if (parsed && Array.isArray(parsed.data)) + return parsed.data.map((v: unknown) => String(v)); + } catch { + return val.split(',').map((s) => s.trim()).filter(Boolean); + } + } + if (val && typeof val === 'object') { + const obj = val as Record; + if (Array.isArray(obj.data)) return obj.data.map((v) => String(v)); + if (Array.isArray(obj.collections)) { + return obj.collections.map((v) => + typeof v === 'string' ? v : String((v as { collection?: string })?.collection ?? v), + ); + } + if (obj.collections && typeof obj.collections === 'object' && !Array.isArray(obj.collections)) { + return Object.keys(obj.collections as object); + } + } + } catch { + /* ignore */ + } + return []; + } + + parseCollectionsWithDescriptions(raw: unknown): Map< + string, + { description: string; keywords: string[] } + > { + const map = new Map(); + const root = raw as any; + const collections = root?.collections ?? raw; + if (!collections || typeof collections !== 'object') return map; + + if (Array.isArray(collections)) { + for (const row of collections) { + const name = String(row?.collection ?? row?.name ?? '').trim(); + if (!name) continue; + map.set(name, { + description: String(row?.description ?? ''), + keywords: Array.isArray(row?.keywords) + ? row.keywords.map((k: unknown) => String(k)) + : [], + }); + } + return map; + } + + for (const [name, meta] of Object.entries(collections as Record)) { + map.set(name, { + description: String(meta?.description ?? ''), + keywords: Array.isArray(meta?.keywords) + ? meta.keywords.map((k: unknown) => String(k)) + : [], + }); + } + return map; + } + + async listAiCollectionNames(): Promise { + const raw = await this.aiService.getCollections(); + return this.coerceCollectionNames(raw); + } + + async listUnlinkedAiCollections() { + const [names, linkedDocs, withDescRaw, checkRaw] = await Promise.all([ + this.listAiCollectionNames(), + this.dictionariesModel + .find({ + $or: [ + { aiCollectionName: { $exists: true, $nin: [null, ''] } }, + { aiCollectionId: { $exists: true, $nin: [null, ''] } }, + ], + }) + .select('aiCollectionName aiCollectionId') + .lean(), + this.aiService.getCollectionsWithDescriptions().catch(() => null), + this.aiService.checkCollections().catch(() => null), + ]); + + const linked = new Set( + linkedDocs + .map((d) => (d.aiCollectionName || d.aiCollectionId || '').toLowerCase()) + .filter(Boolean), + ); + + const descMap = this.parseCollectionsWithDescriptions(withDescRaw); + const countMap = new Map(); + const checkList = (checkRaw as any)?.collections; + if (Array.isArray(checkList)) { + for (const row of checkList) { + const name = String(row?.collection ?? '').trim(); + if (!name) continue; + countMap.set(name, { + count: Number(row?.count ?? 0), + empty: Boolean(row?.empty), + }); + } + } + + return names + .filter((n) => n && !linked.has(n.toLowerCase())) + .map((name) => { + const meta = descMap.get(name) ?? { description: '', keywords: [] as string[] }; + const counts = countMap.get(name); + return { + collection: name, + description: meta.description, + keywords: meta.keywords, + questionCount: counts?.count ?? null, + empty: counts?.empty ?? null, + }; + }); + } + + async syncAllLinked() { + const [aiNames, metaMap, checkRaw] = await Promise.all([ + this.listAiCollectionNames(), + this.aiService + .getCollectionsWithDescriptions() + .then((r) => this.parseCollectionsWithDescriptions(r)) + .catch(() => new Map()), + this.aiService.checkCollections().catch(() => null), + ]); + + const aiNameSet = new Set(aiNames.map((n) => n.toLowerCase())); + const emptyOrMissing = new Map(); + const checkList = (checkRaw as any)?.collections; + if (Array.isArray(checkList)) { + for (const row of checkList) { + const name = String(row?.collection ?? '').trim(); + if (!name) continue; + if (row.empty === true) emptyOrMissing.set(name.toLowerCase(), 'deactivated'); + } + } + + const linked = await this.dictionariesModel.find({ + $or: [ + { aiCollectionName: { $exists: true, $nin: [null, ''] } }, + { aiCollectionId: { $exists: true, $nin: [null, ''] } }, + ], + }); + + const results: Array<{ + dictionaryId: string; + collection: string; + status: string; + error?: string; + }> = []; + + for (const dic of linked) { + const collection = (dic.aiCollectionName || dic.aiCollectionId || '').trim(); + if (!collection) continue; + + const key = collection.toLowerCase(); + if (!aiNameSet.has(key)) { + await this.markMissingOrDeactivated(dic._id, 'missing'); + results.push({ + dictionaryId: String(dic._id), + collection, + status: 'missing', + }); + continue; + } + + if (emptyOrMissing.get(key) === 'deactivated') { + await this.markMissingOrDeactivated(dic._id, 'deactivated'); + // still pull questions so cache stays accurate + } + + try { + const meta = metaMap.get(collection); + await this.pullCollectionIntoDictionary(String(dic._id), collection, meta); + results.push({ + dictionaryId: String(dic._id), + collection, + status: 'ok', + }); + } catch (err: any) { + this.logger.error(`Sync failed for ${collection}`, err); + results.push({ + dictionaryId: String(dic._id), + collection, + status: 'error', + error: err?.message || String(err), + }); + } + } + + return { synced: results.length, results }; + } + + async syncOneByCollectionName(collectionName: string) { + const name = (collectionName || '').trim(); + if (!name) { + throw new HttpException('collection_name_required', HttpStatus.BAD_REQUEST); + } + + const dic = await this.dictionariesModel.findOne({ + $or: [ + { aiCollectionName: name }, + { aiCollectionId: name }, + { aiCollectionName: new RegExp(`^${escapeRegex(name)}$`, 'i') }, + { aiCollectionId: new RegExp(`^${escapeRegex(name)}$`, 'i') }, + ], + }); + if (!dic) { + throw new HttpException('dictionary_not_linked', HttpStatus.NOT_FOUND); + } + + const aiNames = await this.listAiCollectionNames(); + if (!aiNames.some((n) => n.toLowerCase() === name.toLowerCase())) { + await this.markMissingOrDeactivated(dic._id, 'missing'); + throw new HttpException('ai_collection_missing', HttpStatus.NOT_FOUND); + } + + let meta: { description: string; keywords: string[] } | undefined; + try { + const raw = await this.aiService.getCollectionsWithDescriptions(); + meta = this.parseCollectionsWithDescriptions(raw).get( + aiNames.find((n) => n.toLowerCase() === name.toLowerCase()) || name, + ); + } catch { + /* optional */ + } + + await this.pullCollectionIntoDictionary(String(dic._id), dic.aiCollectionName || name, meta); + return { dictionaryId: String(dic._id), collection: dic.aiCollectionName || name, status: 'ok' }; + } + + /** + * Lazy repair: if cache marked stale, re-pull before serving. + */ + async ensureFreshIfStale(dictionaryId: string) { + const dic = await this.dictionariesModel.findById(dictionaryId); + if (!dic) return; + if (dic.aiSyncStatus !== 'stale') return; + const collection = (dic.aiCollectionName || dic.aiCollectionId || '').trim(); + if (!collection) return; + await this.pullCollectionIntoDictionary(String(dic._id), collection); + } + + async markStale(dictionaryId: string | Types.ObjectId) { + await this.dictionariesModel.updateOne( + { _id: dictionaryId }, + { $set: { aiSyncStatus: 'stale' as AiSyncStatus } }, + ); + } + + private async markMissingOrDeactivated( + dictionaryId: Types.ObjectId, + status: 'missing' | 'deactivated', + ) { + await this.dictionariesModel.updateOne( + { _id: dictionaryId }, + { + $set: { + isActive: false, + aiSyncStatus: status, + lastSyncedAt: new Date(), + }, + }, + ); + } + + /** + * Full export merge: upsert by aiItemId, soft-delete absentees. + */ + async pullCollectionIntoDictionary( + dictionaryId: string, + collectionName: string, + meta?: { description: string; keywords: string[] }, + ) { + const lockKey = dictionaryId; + if (this.syncing.has(lockKey)) { + this.logger.warn(`Sync already in progress for dictionary ${dictionaryId}`); + return; + } + this.syncing.add(lockKey); + + try { + const dic = await this.dictionariesModel.findById(dictionaryId); + if (!dic) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + + const exportedItems: Array<{ id: string; q: string; a: string }> = []; + let page = 1; + let totalPages = 1; + + do { + const pageData = await this.aiService.exportCollectionPage( + collectionName, + page, + EXPORT_PAGE_SIZE, + ); + totalPages = Math.max(1, Number(pageData?.total_pages ?? 1)); + const items = Array.isArray(pageData?.items) ? pageData.items : []; + for (const item of items) { + if (item?.id) exportedItems.push(item); + } + page += 1; + } while (page <= totalPages); + + const nowUnix = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(nowUnix); + const existing = Array.isArray(dic.questions) ? [...dic.questions] : []; + const byAiId = new Map(); + for (const q of existing) { + if (q?.aiItemId) byAiId.set(String(q.aiItemId), q); + } + + const seen = new Set(); + const merged: any[] = []; + + for (const item of exportedItems) { + const aiItemId = String(item.id); + seen.add(aiItemId); + const prev = byAiId.get(aiItemId); + if (prev) { + const prevObj = + prev && typeof (prev as any).toObject === 'function' + ? (prev as any).toObject() + : { ...(prev as any) }; + merged.push({ + ...prevObj, + question: item.q, + answer: item.a, + aiItemId, + deleted: false, + updatedAt: nowPersian, + }); + } else { + merged.push({ + _id: new Types.ObjectId(), + question: item.q, + answer: item.a, + aiItemId, + deleted: false, + createdAt: nowPersian, + createdISO: nowUnix * 1000, + updatedAt: nowPersian, + updatedBy: [], + }); + } + } + + // Soft-delete cache rows that disappeared from AI (only those with aiItemId) + for (const q of existing) { + const aiItemId = q?.aiItemId ? String(q.aiItemId) : ''; + const qObj = + q && typeof (q as any).toObject === 'function' + ? (q as any).toObject() + : { ...(q as any) }; + if (!aiItemId) { + // Legacy row without aiItemId: soft-delete so it cannot be edited as SoT + merged.push({ + ...qObj, + deleted: true, + updatedAt: nowPersian, + }); + continue; + } + if (!seen.has(aiItemId)) { + merged.push({ + ...qObj, + deleted: true, + updatedAt: nowPersian, + }); + } + } + + const $set: Record = { + questions: merged, + aiSyncStatus: 'ok' as AiSyncStatus, + lastSyncedAt: new Date(), + aiCollectionName: collectionName, + aiCollectionId: dic.aiCollectionId || collectionName, + isActive: true, + }; + if (meta) { + $set.description = meta.description; + $set.keywords = meta.keywords; + } + + await this.dictionariesModel.updateOne({ _id: dictionaryId }, { $set }); + } finally { + this.syncing.delete(lockKey); + } + } + + async linkDictionaryToCollection(params: { + dictionaryId: string; + collectionName: string; + }) { + const collectionName = params.collectionName.trim(); + const aiNames = await this.listAiCollectionNames(); + const matched = aiNames.find((n) => n.toLowerCase() === collectionName.toLowerCase()); + if (!matched) { + throw new HttpException('ai_collection_not_found', HttpStatus.NOT_FOUND); + } + + const already = await this.dictionariesModel.findOne({ + _id: { $ne: params.dictionaryId }, + $or: [{ aiCollectionName: matched }, { aiCollectionId: matched }], + }); + if (already) { + throw new HttpException('ai_collection_already_linked', HttpStatus.CONFLICT); + } + + const dic = await this.dictionariesModel.findByIdAndUpdate( + params.dictionaryId, + { + $set: { + aiCollectionName: matched, + aiCollectionId: matched, + aiSyncStatus: 'stale', + }, + }, + { new: true }, + ); + if (!dic) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + + await this.pullCollectionIntoDictionary(String(dic._id), matched); + return this.dictionariesModel.findById(dic._id); + } + + async createDictionaryFromAiCollection(params: { + collectionName: string; + category: string; + type: 'Regulation' | 'Method'; + icon: string; + title?: string; + }) { + const collectionName = params.collectionName.trim(); + const aiNames = await this.listAiCollectionNames(); + const matched = aiNames.find((n) => n.toLowerCase() === collectionName.toLowerCase()); + if (!matched) { + throw new HttpException('ai_collection_not_found', HttpStatus.NOT_FOUND); + } + + const existingLink = await this.dictionariesModel.findOne({ + $or: [{ aiCollectionName: matched }, { aiCollectionId: matched }], + }); + if (existingLink) { + throw new HttpException('ai_collection_already_linked', HttpStatus.CONFLICT); + } + + const nowUnix = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(nowUnix); + + let meta: { description: string; keywords: string[] } | undefined; + try { + const raw = await this.aiService.getCollectionsWithDescriptions(); + meta = this.parseCollectionsWithDescriptions(raw).get(matched); + } catch { + /* optional */ + } + + const dic = await this.dictionariesModel.create({ + title: params.title || matched, + category: params.category, + type: params.type, + icon: params.icon, + isActive: true, + fileId: [], + questions: [], + aiCollectionId: matched, + aiCollectionName: matched, + description: meta?.description, + keywords: meta?.keywords ?? [], + aiSyncStatus: 'stale', + createdAt: nowPersian, + createdISO: new Date(nowUnix * 1000), + updatedAt: new Date(), + updatedBy: [], + }); + + await this.pullCollectionIntoDictionary(String(dic._id), matched, meta); + return this.dictionariesModel.findById(dic._id); + } + + /** + * After AI single-item mutate succeeds, patch cache; on failure mark stale and rethrow. + */ + async applyCreateToCache(params: { + dictionaryId: string; + question: string; + answer: string; + aiItemId: string; + adminLabel: string; + }) { + const now = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(now); + const newQuestion = { + _id: new Types.ObjectId(), + question: params.question, + answer: params.answer, + aiItemId: params.aiItemId, + deleted: false, + createdAt: nowPersian, + createdISO: now * 1000, + updatedAt: nowPersian, + updatedBy: [], + }; + + try { + const updated = await this.dictionariesModel.findOneAndUpdate( + { _id: params.dictionaryId }, + { + $push: { questions: newQuestion }, + $set: { lastSyncedAt: new Date(), aiSyncStatus: 'ok' }, + }, + { new: true }, + ); + if (!updated) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + return updated.questions.find( + (q: any) => q.aiItemId === params.aiItemId || q._id.toString() === newQuestion._id.toString(), + ); + } catch (err) { + await this.markStale(params.dictionaryId); + throw err; + } + } + + async applyUpdateToCache(params: { + dictionaryId: string; + questionId: string; + question: string; + answer: string; + adminLabel: string; + }) { + const now = Date.now() / 1000; + try { + const updated = await this.dictionariesModel.findOneAndUpdate( + { + _id: params.dictionaryId, + 'questions._id': new Types.ObjectId(params.questionId), + 'questions.deleted': false, + }, + { + $set: { + 'questions.$[elem].question': params.question, + 'questions.$[elem].answer': params.answer, + 'questions.$[elem].updatedAt': TimeHelper.unix2PersianTimeAndDate(now), + lastSyncedAt: new Date(), + aiSyncStatus: 'ok', + }, + $push: { + 'questions.$[elem].updatedBy': { + name: params.adminLabel, + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + }, + }, + }, + { + arrayFilters: [{ 'elem._id': new Types.ObjectId(params.questionId) }], + new: true, + }, + ); + if (!updated) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + return updated.questions.find((q) => q._id.toString() === params.questionId); + } catch (err) { + await this.markStale(params.dictionaryId); + throw err; + } + } + + async applyDeleteToCache(params: { dictionaryId: string; questionId: string }) { + const now = Date.now() / 1000; + try { + const updated = await this.dictionariesModel.findOneAndUpdate( + { + _id: params.dictionaryId, + 'questions._id': new Types.ObjectId(params.questionId), + 'questions.deleted': false, + }, + { + $set: { + 'questions.$[elem].deleted': true, + 'questions.$[elem].updatedAt': TimeHelper.unix2PersianTimeAndDate(now), + lastSyncedAt: new Date(), + aiSyncStatus: 'ok', + }, + }, + { + arrayFilters: [{ 'elem._id': new Types.ObjectId(params.questionId) }], + new: true, + }, + ); + if (!updated) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + return updated.questions.find((q) => q._id.toString() === params.questionId); + } catch (err) { + await this.markStale(params.dictionaryId); + throw err; + } + } + + extractAiItemIdFromCreateResponse(data: unknown): string | null { + if (!data || typeof data !== 'object') return null; + const d = data as Record; + const candidates = [d.id, d.item_id, d.itemId, (d.item as any)?.id]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) return c.trim(); + } + return null; + } +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/dictionaries/dictionary-file-assets.controller.ts b/src/dictionaries/dictionary-file-assets.controller.ts new file mode 100644 index 0000000..c6f3daa --- /dev/null +++ b/src/dictionaries/dictionary-file-assets.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + HttpStatus, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { Types } from 'mongoose'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminDocument } from 'src/database/model/admin.model'; +import { MAX_DICTIONARY_BYTES } from 'src/storage/storage.constants'; +import { DictionaryFileAssetsService } from './dictionary-file-assets.service'; + +class DictionaryAssetUploadBodyDto { + @IsString() + @IsNotEmpty() + category!: string; +} + +@ApiTags('dictionary & question') +@Controller('dictionaries') +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.DictionariesAssets) +export class DictionaryFileAssetsController { + constructor(private readonly assetsService: DictionaryFileAssetsService) {} + + @Post('assets') + @ApiOperation({ + summary: + 'Upload dictionary source CSV file to object storage (CSV only); metadata saved in MongoDB.', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'category'], + properties: { + file: { + type: 'string', + format: 'binary', + description: 'text/csv only', + }, + category: { + type: 'string', + example: 'lifeInsurance', + description: 'Sanitized category segment for the object key', + }, + }, + }, + }) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: MAX_DICTIONARY_BYTES }, + }), + ) + async uploadAsset( + @UploadedFile() file: Express.Multer.File, + @Body() body: DictionaryAssetUploadBodyDto, + @AdminIdentity() admin: AdminDocument, + ) { + if (!body?.category?.trim()) { + return new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'category_required', {}); + } + const data = await this.assetsService.upload({ + category: body.category, + adminId: new Types.ObjectId(String(admin._id)), + file, + }); + return new BaseResponseDTO(HttpStatus.CREATED, 'dictionary_asset_uploaded', data); + } +} diff --git a/src/dictionaries/dictionary-file-assets.service.ts b/src/dictionaries/dictionary-file-assets.service.ts new file mode 100644 index 0000000..f32e208 --- /dev/null +++ b/src/dictionaries/dictionary-file-assets.service.ts @@ -0,0 +1,71 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { + DICTIONARY_ALLOWED_MIMES, + MAX_DICTIONARY_BYTES, +} from 'src/storage/storage.constants'; +import { buildDictionaryStorageKey, extensionFromMime } from 'src/storage/key-builder.helper'; +import { StorageService } from 'src/storage/storage.service'; +import { DictionaryFileAssetModel } from 'src/database/model/dictionary-file-asset.model'; + +@Injectable() +export class DictionaryFileAssetsService { + constructor( + private readonly storage: StorageService, + @InjectModel(DictionaryFileAssetModel.name) + private readonly assets: Model, + ) {} + + async upload(params: { + category: string; + adminId: Types.ObjectId; + file: Express.Multer.File; + }) { + const { category, adminId, file } = params; + if (!file?.buffer?.length) { + throw new BadRequestException('file_required'); + } + if (file.size > MAX_DICTIONARY_BYTES) { + throw new BadRequestException('dictionary_file_too_large'); + } + const mime = (file.mimetype || '').toLowerCase().trim(); + if (!DICTIONARY_ALLOWED_MIMES.has(mime)) { + throw new BadRequestException('invalid_dictionary_mime'); + } + + const ext = extensionFromMime(mime); + const key = buildDictionaryStorageKey(category, ext); + + await this.storage.putPrivateObject({ + key, + body: file.buffer, + contentType: mime, + contentLength: file.size, + metadata: { + category: params.category.trim(), + uploadedby: adminId.toHexString(), + }, + }); + + const doc = await this.assets.create({ + category: params.category.trim(), + uploadedBy: adminId, + originalFilename: file.originalname || 'upload', + storageKey: key, + bucket: 'private', + mimeType: mime, + size: file.size, + }); + + return { + id: doc._id.toString(), + category: doc.category, + storageKey: doc.storageKey, + bucket: doc.bucket, + mimeType: doc.mimeType, + size: doc.size, + uploadedAt: doc.get('uploadedAt') as Date, + }; + } +} diff --git a/src/dictionaries/dto/ai-sync.dto.ts b/src/dictionaries/dto/ai-sync.dto.ts new file mode 100644 index 0000000..7006d2b --- /dev/null +++ b/src/dictionaries/dto/ai-sync.dto.ts @@ -0,0 +1,41 @@ +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class LinkAiCollectionDto { + @ApiProperty({ example: '64f1a2b3c4d5e6f7a8b9c0d1' }) + @IsString() + @IsNotEmpty() + dictionaryId: string; + + @ApiProperty({ example: 'shoab' }) + @IsString() + @IsNotEmpty() + collectionName: string; +} + +export class CreateDictionaryFromAiDto { + @ApiProperty({ example: 'shoab' }) + @IsString() + @IsNotEmpty() + collectionName: string; + + @ApiProperty({ description: 'App category id or enTitle' }) + @IsString() + @IsNotEmpty() + category: string; + + @ApiProperty({ enum: ['Regulation', 'Method'] }) + @IsString() + @IsIn(['Regulation', 'Method']) + type: 'Regulation' | 'Method'; + + @ApiProperty() + @IsString() + @IsNotEmpty() + icon: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + title?: string; +} diff --git a/src/dictionaries/dto/create-dictionary.dto.ts b/src/dictionaries/dto/create-dictionary.dto.ts new file mode 100644 index 0000000..5f8fac7 --- /dev/null +++ b/src/dictionaries/dto/create-dictionary.dto.ts @@ -0,0 +1,150 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CategoriesEnum } from 'src/common/types/categories.type'; +import { funcsEnum } from 'src/common/types/func.type'; + +export class CreateDictionaryDto { + @ApiProperty({ + required: true, + type: 'array', + description: 'array of ids of the uploaded csv files', + example: ['678e38c62a15cd407a48ccbf', '678e38c62a15cd407a48ccbd'], + }) + fileIds: string[]; + + @ApiProperty({ + required: true, + type: 'string', + enum: funcsEnum, + enumName: 'funcsEnum', + description: 'func to what to do with the dictionary/ create , update , replace', + example: 'create/update/replace', + }) + func: string; + + @ApiProperty({ + required: true, + type: 'string', + enum: CategoriesEnum, + enumName: 'CategoriesEnum', + description: 'category of the uploaded dictionary', + example: 'carInsurance', + }) + category: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'name of the icon picture name', + example: 'carInsurance.png', + }) + icon: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'description of the uploading dictionary', + example: 'توضیحات مربوط به دانشنامه درحال بارگزاری', + }) + description: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'Title of the file to replace (only for func = "replace")', + example: 'responsibility_insurance-15.csv', + }) + fileTitleToReplace?: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'type of the dictionary it could be Regulation | Method', + example: 'Regulation', + }) + type: string; +} + +export class ModificationRequestDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'id of the modification request', + example: '678e38c62a15cd407a48ccbf', + }) + requestId: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of old question', + example: '', + }) + oldQuestion: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of old answer', + example: '', + }) + oldAnswer: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of new question', + example: '', + }) + newQuestion: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of new answer', + example: '', + }) + newAnswer: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: 'status of approved or not. Approve=true , reject=false', + example: 'true', + }) + approved: boolean; + + @ApiProperty({ + required: false, + type: 'string', + description: + 'if there is a reason request is not approved in text format. No need in current version', + example: '', + }) + rejectionReason: boolean; +} + +export class CreateQuestionDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'question string', + example: "این متن سوال است.", + }) + question: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'answer string', + example: "این متن پاسخ است.", + }) + answer: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'id of the dictionary', + example: '678e38c62a15cd407a48ccbf', + }) + dictionaryId: string; +} \ No newline at end of file diff --git a/src/dictionaries/dto/update-dictionary.dto.ts b/src/dictionaries/dto/update-dictionary.dto.ts new file mode 100644 index 0000000..0106dc3 --- /dev/null +++ b/src/dictionaries/dto/update-dictionary.dto.ts @@ -0,0 +1,82 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Types } from 'mongoose'; + +export class UpdateDictionaryDto { + @ApiProperty({ + required: false, + type: 'string', + description: 'title of the dictionary', + example: 'دانشنامه شماره یک ویرایش پنجم', + }) + title: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'name of the category', + example: 'carInsurance', + }) + category: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'name of the icon picture name', + example: 'carInsurance.png', + }) + icon: string; +} + +export class UpdateQuestionDto { + @ApiProperty({ + required: false, + type: 'string', + description: 'updated question', + example: 'سوال ویرایش شده', + }) + question: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'updated answer', + example: 'پاسخ ویرایش شده.', + }) + answer: string; +} + +export class QuestionDto { + @ApiProperty({ required: false, type: String }) + _id?: Types.ObjectId; + + @ApiProperty({ + required: true, + type: String, + description: 'The question string', + example: 'Is car insurance mandatory in Iran?', + }) + question: string; + + @ApiProperty({ + required: true, + type: String, + description: 'The answer string', + example: 'Yes, car insurance is mandatory in Iran.', + }) + answer: string; + + @ApiProperty({ required: false, type: Boolean, default: false }) + deleted?: boolean; + + @ApiProperty({ required: false, type: [String], example: ['12:00', '1402-01-01'] }) + createdAt?: [string, string]; + + @ApiProperty({ required: false, type: Number, example: 1672531200000 }) + createdISO?: number; + + @ApiProperty({ required: false, type: Object }) + updatedAt?: { time: string; date: string }; + + @ApiProperty({ required: false, type: [Object] }) + updatedBy?: Array<{ name: string; updatedAt: { time: string; date: string } }>; +} diff --git a/src/expert-prepared-messages/dto/create-expert-prepared-message.dto.ts b/src/expert-prepared-messages/dto/create-expert-prepared-message.dto.ts new file mode 100644 index 0000000..c4f486b --- /dev/null +++ b/src/expert-prepared-messages/dto/create-expert-prepared-message.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +export class CreateExpertPreparedMessageDto { + @ApiProperty({ + example: 'سلام، چطور می‌تونم کمکتون کنم؟', + description: 'Full text inserted into the chat when the expert selects this snippet', + }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + message: string; + + @ApiProperty({ + enum: EXPERT_PREPARED_MESSAGE_CATEGORIES, + example: ExpertPreparedMessageCategory.Greetings, + }) + @IsEnum(ExpertPreparedMessageCategory) + category: ExpertPreparedMessageCategory; + + @ApiPropertyOptional({ + example: 'سلام', + description: 'Short label shown on the chip/button in the expert chat UI', + }) + @IsOptional() + @IsString() + @MaxLength(120) + label?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @ApiPropertyOptional({ default: 0, description: 'Lower values appear first within a category' }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @ApiPropertyOptional({ default: 'fa', example: 'fa' }) + @IsOptional() + @IsString() + @MaxLength(10) + locale?: string; +} diff --git a/src/expert-prepared-messages/dto/expert-prepared-messages-query.dto.ts b/src/expert-prepared-messages/dto/expert-prepared-messages-query.dto.ts new file mode 100644 index 0000000..733455b --- /dev/null +++ b/src/expert-prepared-messages/dto/expert-prepared-messages-query.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +export class ExpertPreparedMessagesLocaleQueryDto { + @ApiPropertyOptional({ default: 'fa', example: 'fa' }) + @IsOptional() + @IsString() + @MaxLength(10) + locale?: string; +} + +export class ExpertPreparedMessagesByCategoryQueryDto extends ExpertPreparedMessagesLocaleQueryDto { + @ApiProperty({ + enum: EXPERT_PREPARED_MESSAGE_CATEGORIES, + example: ExpertPreparedMessageCategory.Greetings, + }) + @IsEnum(ExpertPreparedMessageCategory) + category: ExpertPreparedMessageCategory; +} diff --git a/src/expert-prepared-messages/dto/list-expert-prepared-messages-query.dto.ts b/src/expert-prepared-messages/dto/list-expert-prepared-messages-query.dto.ts new file mode 100644 index 0000000..c27a664 --- /dev/null +++ b/src/expert-prepared-messages/dto/list-expert-prepared-messages-query.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; +import { PageOptionsDto } from 'src/common/dto/base-response.dto'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +export class ListExpertPreparedMessagesQueryDto extends PageOptionsDto { + @ApiPropertyOptional({ enum: EXPERT_PREPARED_MESSAGE_CATEGORIES }) + @IsOptional() + @IsEnum(ExpertPreparedMessageCategory) + category?: ExpertPreparedMessageCategory; + + @ApiPropertyOptional({ description: 'Filter by enabled flag' }) + @IsOptional() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + @IsBoolean() + isEnabled?: boolean; + + @ApiPropertyOptional({ example: 'fa' }) + @IsOptional() + @IsString() + locale?: string; +} diff --git a/src/expert-prepared-messages/dto/reorder-expert-prepared-messages.dto.ts b/src/expert-prepared-messages/dto/reorder-expert-prepared-messages.dto.ts new file mode 100644 index 0000000..964240d --- /dev/null +++ b/src/expert-prepared-messages/dto/reorder-expert-prepared-messages.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsInt, + IsMongoId, + IsNotEmpty, + Min, + ValidateNested, +} from 'class-validator'; + +export class ReorderExpertPreparedMessageItemDto { + @ApiProperty({ example: '507f1f77bcf86cd799439011' }) + @IsMongoId() + @IsNotEmpty() + id: string; + + @ApiProperty({ example: 0 }) + @IsInt() + @Min(0) + sortOrder: number; +} + +export class ReorderExpertPreparedMessagesDto { + @ApiProperty({ type: [ReorderExpertPreparedMessageItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ReorderExpertPreparedMessageItemDto) + items: ReorderExpertPreparedMessageItemDto[]; +} diff --git a/src/expert-prepared-messages/dto/update-expert-prepared-message.dto.ts b/src/expert-prepared-messages/dto/update-expert-prepared-message.dto.ts new file mode 100644 index 0000000..9b362f6 --- /dev/null +++ b/src/expert-prepared-messages/dto/update-expert-prepared-message.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateExpertPreparedMessageDto } from './create-expert-prepared-message.dto'; + +export class UpdateExpertPreparedMessageDto extends PartialType( + CreateExpertPreparedMessageDto, +) {} diff --git a/src/expert-prepared-messages/expert-prepared-messages-admin.controller.ts b/src/expert-prepared-messages/expert-prepared-messages-admin.controller.ts new file mode 100644 index 0000000..2118066 --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages-admin.controller.ts @@ -0,0 +1,126 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminModel } from 'src/database/model/admin.model'; +import { CreateExpertPreparedMessageDto } from './dto/create-expert-prepared-message.dto'; +import { ListExpertPreparedMessagesQueryDto } from './dto/list-expert-prepared-messages-query.dto'; +import { ReorderExpertPreparedMessagesDto } from './dto/reorder-expert-prepared-messages.dto'; +import { UpdateExpertPreparedMessageDto } from './dto/update-expert-prepared-message.dto'; +import { ExpertPreparedMessagesService } from './expert-prepared-messages.service'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.PreparedMessagesManage) +@ApiTags('expert-prepared-messages (admin)') +@Controller('admin/expert-prepared-messages') +export class ExpertPreparedMessagesAdminController { + constructor( + private readonly preparedMessagesService: ExpertPreparedMessagesService, + ) {} + + @Get('categories') + @ApiOperation({ summary: 'List available prepared-message categories' }) + getCategories() { + return { + statusCode: HttpStatus.OK, + message: 'SUCCESS', + data: this.preparedMessagesService.getCategories(), + }; + } + + @Get() + @ApiOperation({ + summary: 'List all prepared messages for expert online chat (admin management)', + }) + findAll(@Query() query: ListExpertPreparedMessagesQueryDto) { + return this.preparedMessagesService.findAllForAdmin(query); + } + + @Post() + @ApiOperation({ summary: 'Create a prepared message snippet' }) + create( + @Body() dto: CreateExpertPreparedMessageDto, + @AdminIdentity() admin: AdminModel, + ) { + return this.preparedMessagesService.create(dto, admin.username); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a prepared message snippet' }) + @ApiParam({ name: 'id' }) + update( + @Param('id') id: string, + @Body() dto: UpdateExpertPreparedMessageDto, + @AdminIdentity() admin: AdminModel, + ) { + try { + return this.preparedMessagesService.update(id, dto, admin.username); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new HttpException( + error.message || 'Failed to update prepared message', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Patch('reorder') + @ApiOperation({ summary: 'Bulk update sort order for prepared messages' }) + reorder( + @Body() dto: ReorderExpertPreparedMessagesDto, + @AdminIdentity() admin: AdminModel, + ) { + return this.preparedMessagesService.reorder(dto, admin.username); + } + + @Patch(':id/toggle') + @ApiOperation({ summary: 'Enable or disable a prepared message snippet' }) + @ApiParam({ name: 'id' }) + toggle(@Param('id') id: string, @AdminIdentity() admin: AdminModel) { + try { + return this.preparedMessagesService.toggleEnabled(id, admin.username); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new HttpException( + error.message || 'Failed to toggle prepared message', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a prepared message snippet' }) + @ApiParam({ name: 'id' }) + remove(@Param('id') id: string) { + try { + return this.preparedMessagesService.remove(id); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new HttpException( + error.message || 'Failed to delete prepared message', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/expert-prepared-messages/expert-prepared-messages-expert.controller.ts b/src/expert-prepared-messages/expert-prepared-messages-expert.controller.ts new file mode 100644 index 0000000..fb53aa2 --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages-expert.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { + ExpertPreparedMessagesByCategoryQueryDto, + ExpertPreparedMessagesLocaleQueryDto, +} from './dto/expert-prepared-messages-query.dto'; +import { ExpertPreparedMessagesService } from './expert-prepared-messages.service'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.PreparedMessagesRead) +@ApiTags('expert-prepared-messages (expert)') +@Controller('expert/prepared-messages') +export class ExpertPreparedMessagesExpertController { + constructor( + private readonly preparedMessagesService: ExpertPreparedMessagesService, + ) {} + + @Get('categories') + @ApiOperation({ + summary: + 'List prepared-message categories that have at least one enabled message', + }) + @ApiQuery({ + name: 'locale', + required: false, + example: 'fa', + description: 'Locale filter (defaults to fa)', + }) + findCategories(@Query() query: ExpertPreparedMessagesLocaleQueryDto) { + return this.preparedMessagesService.findEnabledCategoriesForExpert( + query.locale || 'fa', + ); + } + + @Get() + @ApiOperation({ + summary: 'Get enabled prepared messages for a specific category', + }) + @ApiQuery({ + name: 'category', + required: true, + enum: [ + 'greetings', + 'quick_answers', + 'closing', + 'follow_up', + 'apology', + 'other', + ], + }) + @ApiQuery({ + name: 'locale', + required: false, + example: 'fa', + description: 'Locale filter (defaults to fa)', + }) + findByCategory(@Query() query: ExpertPreparedMessagesByCategoryQueryDto) { + return this.preparedMessagesService.findEnabledByCategoryForExpert( + query.category, + query.locale || 'fa', + ); + } +} diff --git a/src/expert-prepared-messages/expert-prepared-messages.module.ts b/src/expert-prepared-messages/expert-prepared-messages.module.ts new file mode 100644 index 0000000..598fbde --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { DatabaseModule } from 'src/database/database.module'; +import { + ExpertPreparedMessageModel, + ExpertPreparedMessageSchema, +} from 'src/database/model/expert-prepared-message.model'; +import { ExpertPreparedMessagesAdminController } from './expert-prepared-messages-admin.controller'; +import { ExpertPreparedMessagesExpertController } from './expert-prepared-messages-expert.controller'; +import { ExpertPreparedMessagesService } from './expert-prepared-messages.service'; + +@Module({ + imports: [ + DatabaseModule, + MongooseModule.forFeature([ + { + name: ExpertPreparedMessageModel.name, + schema: ExpertPreparedMessageSchema, + }, + ]), + ], + controllers: [ + ExpertPreparedMessagesAdminController, + ExpertPreparedMessagesExpertController, + ], + providers: [ExpertPreparedMessagesService], + exports: [ExpertPreparedMessagesService], +}) +export class ExpertPreparedMessagesModule {} diff --git a/src/expert-prepared-messages/expert-prepared-messages.service.ts b/src/expert-prepared-messages/expert-prepared-messages.service.ts new file mode 100644 index 0000000..65f84a4 --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages.service.ts @@ -0,0 +1,263 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { FilterQuery, Model } from 'mongoose'; +import { + BaseResponseDTO, + PageMetaDto, +} from 'src/common/dto/base-response.dto'; +import { + EXPERT_PREPARED_MESSAGE_CATEGORIES, + EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS, + ExpertPreparedMessageCategory, +} from 'src/common/types/expert-prepared-message-category.type'; +import { ExpertPreparedMessageModel } from 'src/database/model/expert-prepared-message.model'; +import { CreateExpertPreparedMessageDto } from './dto/create-expert-prepared-message.dto'; +import { ListExpertPreparedMessagesQueryDto } from './dto/list-expert-prepared-messages-query.dto'; +import { ReorderExpertPreparedMessagesDto } from './dto/reorder-expert-prepared-messages.dto'; +import { UpdateExpertPreparedMessageDto } from './dto/update-expert-prepared-message.dto'; + +export type ExpertPreparedMessageItem = { + id: string; + message: string; + label: string; + category: ExpertPreparedMessageCategory; + sortOrder: number; + locale: string; +}; + +export type ExpertPreparedMessageCategorySummary = { + value: ExpertPreparedMessageCategory; + label: string; + messageCount: number; +}; + +@Injectable() +export class ExpertPreparedMessagesService { + constructor( + @InjectModel(ExpertPreparedMessageModel.name) + private readonly preparedMessageModel: Model, + ) {} + + getCategories() { + return EXPERT_PREPARED_MESSAGE_CATEGORIES.map((value) => ({ + value, + label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[value], + })); + } + + async findAllForAdmin(query: ListExpertPreparedMessagesQueryDto) { + const filter: FilterQuery = {}; + if (query.category) filter.category = query.category; + if (query.isEnabled !== undefined) filter.isEnabled = query.isEnabled; + if (query.locale) filter.locale = query.locale; + + const [items, itemCount] = await Promise.all([ + this.preparedMessageModel + .find(filter) + .sort({ category: 1, sortOrder: 1, createdAt: 1 }) + .skip(query.skip) + .limit(query.take) + .lean() + .exec(), + this.preparedMessageModel.countDocuments(filter).exec(), + ]); + + const meta = new PageMetaDto({ pageOptionsDto: query, itemCount }); + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared messages retrieved successfully', + items.map((doc) => this.toAdminDto(doc)), + meta, + ); + } + + async findEnabledCategoriesForExpert(locale = 'fa') { + const counts = await this.preparedMessageModel + .aggregate<{ _id: ExpertPreparedMessageCategory; count: number }>([ + { $match: { isEnabled: true, locale } }, + { $group: { _id: '$category', count: { $sum: 1 } } }, + ]) + .exec(); + + const countByCategory = new Map( + counts.map((row) => [row._id, row.count]), + ); + + const categories: ExpertPreparedMessageCategorySummary[] = + EXPERT_PREPARED_MESSAGE_CATEGORIES.filter((value) => + countByCategory.has(value), + ).map((value) => ({ + value, + label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[value], + messageCount: countByCategory.get(value) ?? 0, + })); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + locale, + categories, + }); + } + + async findEnabledByCategoryForExpert( + category: ExpertPreparedMessageCategory, + locale = 'fa', + ) { + const docs = await this.preparedMessageModel + .find({ isEnabled: true, locale, category }) + .sort({ sortOrder: 1, createdAt: 1 }) + .lean() + .exec(); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + locale, + category, + label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[category], + messages: docs.map((doc) => this.toExpertItem(doc)), + }); + } + + async create(dto: CreateExpertPreparedMessageDto, adminUsername: string) { + const sortOrder = + dto.sortOrder ?? + (await this.nextSortOrderForCategory(dto.category, dto.locale ?? 'fa')); + + const created = await this.preparedMessageModel.create({ + message: dto.message.trim(), + category: dto.category, + label: (dto.label ?? '').trim(), + isEnabled: dto.isEnabled ?? true, + sortOrder, + locale: (dto.locale ?? 'fa').trim(), + createdBy: adminUsername, + updatedBy: adminUsername, + }); + + return new BaseResponseDTO( + HttpStatus.CREATED, + 'Expert prepared message created successfully', + this.toAdminDto(created.toObject()), + ); + } + + async update( + id: string, + dto: UpdateExpertPreparedMessageDto, + adminUsername: string, + ) { + const existing = await this.preparedMessageModel.findById(id).exec(); + if (!existing) { + throw new HttpException( + 'prepared_message_not_found', + HttpStatus.NOT_FOUND, + ); + } + + if (dto.message !== undefined) existing.message = dto.message.trim(); + if (dto.category !== undefined) existing.category = dto.category; + if (dto.label !== undefined) existing.label = dto.label.trim(); + if (dto.isEnabled !== undefined) existing.isEnabled = dto.isEnabled; + if (dto.sortOrder !== undefined) existing.sortOrder = dto.sortOrder; + if (dto.locale !== undefined) existing.locale = dto.locale.trim(); + existing.updatedBy = adminUsername; + + const saved = await existing.save(); + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared message updated successfully', + this.toAdminDto(saved.toObject()), + ); + } + + async toggleEnabled(id: string, adminUsername: string) { + const existing = await this.preparedMessageModel.findById(id).exec(); + if (!existing) { + throw new HttpException( + 'prepared_message_not_found', + HttpStatus.NOT_FOUND, + ); + } + + existing.isEnabled = !existing.isEnabled; + existing.updatedBy = adminUsername; + const saved = await existing.save(); + + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared message toggled successfully', + this.toAdminDto(saved.toObject()), + ); + } + + async reorder(dto: ReorderExpertPreparedMessagesDto, adminUsername: string) { + const bulk = dto.items.map((item) => ({ + updateOne: { + filter: { _id: item.id }, + update: { $set: { sortOrder: item.sortOrder, updatedBy: adminUsername } }, + }, + })); + + await this.preparedMessageModel.bulkWrite(bulk); + + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared messages reordered successfully', + null, + ); + } + + async remove(id: string) { + const result = await this.preparedMessageModel.findByIdAndDelete(id).exec(); + if (!result) { + throw new HttpException( + 'prepared_message_not_found', + HttpStatus.NOT_FOUND, + ); + } + + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared message deleted successfully', + null, + ); + } + + private async nextSortOrderForCategory( + category: ExpertPreparedMessageCategory, + locale: string, + ): Promise { + const last = await this.preparedMessageModel + .findOne({ category, locale }) + .sort({ sortOrder: -1 }) + .select({ sortOrder: 1 }) + .lean() + .exec(); + return (last?.sortOrder ?? -1) + 1; + } + + private toExpertItem(doc: any): ExpertPreparedMessageItem { + return { + id: String(doc._id), + message: doc.message, + label: doc.label || doc.message.slice(0, 40), + category: doc.category, + sortOrder: doc.sortOrder ?? 0, + locale: doc.locale ?? 'fa', + }; + } + + private toAdminDto(doc: any) { + return { + id: String(doc._id), + message: doc.message, + label: doc.label ?? '', + category: doc.category, + isEnabled: doc.isEnabled, + sortOrder: doc.sortOrder ?? 0, + locale: doc.locale ?? 'fa', + createdBy: doc.createdBy ?? null, + updatedBy: doc.updatedBy ?? null, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } +} diff --git a/src/externals/inquiries/inquiries.module.ts b/src/externals/inquiries/inquiries.module.ts new file mode 100644 index 0000000..74e12ca --- /dev/null +++ b/src/externals/inquiries/inquiries.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { InquiriesService } from './inquiries.service'; + +@Module({ + providers: [InquiriesService], +}) +export class InquiriesModule {} diff --git a/src/externals/inquiries/inquiries.service.ts b/src/externals/inquiries/inquiries.service.ts new file mode 100644 index 0000000..c080f2b --- /dev/null +++ b/src/externals/inquiries/inquiries.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class InquiriesService {} diff --git a/src/externals/saman-insurance/dto/installment-response.dto.ts b/src/externals/saman-insurance/dto/installment-response.dto.ts new file mode 100644 index 0000000..2bf6b75 --- /dev/null +++ b/src/externals/saman-insurance/dto/installment-response.dto.ts @@ -0,0 +1,25 @@ +export interface InstallmentDto { + policyId: number; + paymentMethod: string; + installmentId: string; + installmentYear: string; + installmentNumber: number; + installmentPaymentAmount: number; + remainingAmount: number; + installmentDate: string; + installmentPaymentDate?: string; + installmentStatus: string; + totalInstallments: number; + paidInstallments: number; + upcomingInstallmentCount: number; + overdueInstallmentCount: number; + bankId: string; + totalPremiumCollected: number; +} + +export interface InstallmentsResponseDto { + policyHolderInstallments: InstallmentDto[]; + errors: Record; +} + +// Made with Bob diff --git a/src/externals/saman-insurance/dto/policy-response.dto.ts b/src/externals/saman-insurance/dto/policy-response.dto.ts new file mode 100644 index 0000000..ad54ebb --- /dev/null +++ b/src/externals/saman-insurance/dto/policy-response.dto.ts @@ -0,0 +1,39 @@ +export interface PolicyDto { + insuredName: string; + policyId: number; + policyNumber: string; + insuranceLineName: string; + insuranceLineCode: number; + policyHolderName: string; + policyIssueDate: string; + policyBeginDate: string; + policyEndDate: string; + referrerName: string; + issuingUnit: string; + uniquePolicyCode: string; + policyStatusCode: number; + policyStatus: string; + policyHolderCode: string; + agentCode: number; +} + +export interface PoliciesResponseDto { + fireInsurancePolicies: PolicyDto[]; + carInsurancePolicies: PolicyDto[]; + healthInsurancePolicies: PolicyDto[]; + cargoInsurancePolicies: PolicyDto[]; + equipmentInsurancePolicies: PolicyDto[]; + lifeInsurancePolicies: PolicyDto[]; + travelInsurancePolicies: PolicyDto[]; + liabilityInsurancePolicies: PolicyDto[]; + personalAccidentInsurancePolicies: PolicyDto[]; + errors: Record; +} + +export interface TokenResponseDto { + userName: string; + token: string; + expirtTime: string; // Note: API has typo "expirtTime" instead of "expireTime" +} + +// Made with Bob diff --git a/src/externals/saman-insurance/saman-insurance.module.ts b/src/externals/saman-insurance/saman-insurance.module.ts new file mode 100644 index 0000000..ec634bb --- /dev/null +++ b/src/externals/saman-insurance/saman-insurance.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { SamanInsuranceService } from './saman-insurance.service'; +import { + ExternalTokensModel, + ExternalTokenSchema, +} from 'src/database/model/externalTokens.model'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: ExternalTokensModel.name, schema: ExternalTokenSchema }, + ]), + ], + providers: [SamanInsuranceService], + exports: [SamanInsuranceService], +}) +export class SamanInsuranceModule {} + +// Made with Bob diff --git a/src/externals/saman-insurance/saman-insurance.service.ts b/src/externals/saman-insurance/saman-insurance.service.ts new file mode 100644 index 0000000..59f8346 --- /dev/null +++ b/src/externals/saman-insurance/saman-insurance.service.ts @@ -0,0 +1,381 @@ +import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import axios, { AxiosInstance } from 'axios'; +import { ExternalTokensModel } from 'src/database/model/externalTokens.model'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { + PoliciesResponseDto, + TokenResponseDto, +} from './dto/policy-response.dto'; +import { InstallmentsResponseDto } from './dto/installment-response.dto'; + +@Injectable() +export class SamanInsuranceService { + private readonly logger = new Logger(SamanInsuranceService.name); + private readonly axiosInstance: AxiosInstance; + private readonly baseUrl = + process.env.SAMAN_API_BASE_URL || 'https://tsisapi.si24.ir/api/v1'; + private readonly tokenScope = 'saman-insurance-api'; + private readonly tokenBufferMs = + parseInt(process.env.SAMAN_API_TOKEN_BUFFER_MINUTES || '5', 10) * + 60 * + 1000; + + /** In-memory cache so all requests share one token without hitting DB every time */ + private memoryCache: { token: string; expiresAtMs: number } | null = null; + /** Prevents concurrent logins from requesting multiple tokens at once */ + private refreshPromise: Promise | null = null; + + constructor( + @InjectModel(ExternalTokensModel.name) + private readonly externalTokens: Model, + ) { + this.axiosInstance = axios.create({ + baseURL: this.baseUrl, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + /** + * Returns a shared app-level token reused until near expiry. + * This is NOT per-user — one token serves all policy/installment requests. + */ + async getValidToken(): Promise { + if (this.isTokenUsable(this.memoryCache)) { + this.logger.debug('Using in-memory cached Saman insurance API token'); + return this.memoryCache.token; + } + + const stored = await this.findValidStoredToken(); + if (stored) { + this.memoryCache = stored; + this.logger.log('Using stored Saman insurance API token from externalTokens'); + return stored.token; + } + + return this.refreshToken(); + } + + private isTokenUsable( + cache: { token: string; expiresAtMs: number } | null, + ): cache is { token: string; expiresAtMs: number } { + return ( + Boolean(cache?.token) && + cache.expiresAtMs - Date.now() > this.tokenBufferMs + ); + } + + private resolveExpiryMs( + expirtTime?: string, + issuedAtUnix?: number, + ): number { + if (expirtTime) { + const numeric = Number(expirtTime); + if (Number.isFinite(numeric) && numeric > 1_000_000_000) { + return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(expirtTime); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + const issuedMs = (issuedAtUnix ?? Math.floor(Date.now() / 1000)) * 1000; + const fallbackHours = parseInt( + process.env.SAMAN_API_TOKEN_FALLBACK_TTL_HOURS || '23', + 10, + ); + return issuedMs + fallbackHours * 60 * 60 * 1000; + } + + private async findValidStoredToken(): Promise<{ + token: string; + expiresAtMs: number; + } | null> { + const existingToken = await this.externalTokens + .findOne({ + scope: this.tokenScope, + url: this.baseUrl, + isActive: true, + }) + .sort({ timestamps: -1 }) + .lean() + .exec(); + + if (!existingToken?.token) { + return null; + } + + const expiresAtMs = this.resolveExpiryMs( + existingToken.expiresIn, + Number(existingToken.timestamps), + ); + + if (expiresAtMs - Date.now() <= this.tokenBufferMs) { + this.logger.log( + 'Stored Saman insurance API token expired or near expiry, will refresh', + ); + await this.externalTokens.updateOne( + { _id: existingToken._id }, + { $set: { isActive: false } }, + ); + return null; + } + + return { + token: existingToken.token, + expiresAtMs, + }; + } + + private async refreshToken(): Promise { + if (this.refreshPromise) { + this.logger.debug( + 'Waiting for in-flight Saman insurance API token refresh', + ); + return this.refreshPromise; + } + + this.refreshPromise = this.fetchAndStoreToken().finally(() => { + this.refreshPromise = null; + }); + + return this.refreshPromise; + } + + private async invalidateStoredTokens(): Promise { + this.memoryCache = null; + await this.externalTokens.updateMany( + { scope: this.tokenScope, url: this.baseUrl }, + { $set: { isActive: false } }, + ); + } + + private async fetchAndStoreToken(): Promise { + const username = process.env.SAMAN_API_USERNAME || 'ChatbotTest'; + const password = process.env.SAMAN_API_PASSWORD || 'C@vn6ym9gXzqAj5gbRe'; + + this.logger.log('Fetching new shared Saman insurance API token'); + + const response = await this.axiosInstance.get( + '/users/authenticate/', + { + params: { username, password }, + }, + ); + + if (!response.data?.token) { + throw new Error('Invalid token response from Saman API'); + } + + const { token, expirtTime } = response.data; + const now = Math.floor(Date.now() / 1000); + const expiresAtMs = this.resolveExpiryMs(expirtTime, now); + + await this.invalidateStoredTokens(); + + await this.externalTokens.create({ + token, + url: this.baseUrl, + isActive: true, + method: 'access', + tokenType: 'Bearer', + expiresIn: expirtTime, + scope: this.tokenScope, + timestamps: now, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + }); + + this.memoryCache = { token, expiresAtMs }; + this.logger.log( + `New Saman insurance API token stored (scope=${this.tokenScope}, expires~${new Date(expiresAtMs).toISOString()})`, + ); + + return token; + } + + private async withAuthorizedRequest( + operation: (token: string) => Promise, + ): Promise { + const token = await this.getValidToken(); + + try { + return await operation(token); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + this.logger.warn( + 'Saman insurance API token rejected with 401, refreshing once', + ); + await this.invalidateStoredTokens(); + const freshToken = await this.refreshToken(); + return operation(freshToken); + } + throw error; + } + } + + async fetchPolicies(nationalCode: string): Promise { + try { + this.logger.log(`Fetching policies for national code: ${nationalCode}`); + + const response = await this.withAuthorizedRequest((token) => + this.axiosInstance.get( + '/Core/policyholder/List-Of-Policies', + { + params: { NationalCode: nationalCode }, + headers: { Authorization: `Bearer ${token}` }, + }, + ), + ); + + if (!response.data) { + throw new Error('Invalid response from Saman API'); + } + + this.logger.log( + `Successfully fetched policies for national code: ${nationalCode}`, + ); + return response.data; + } catch (error) { + this.logger.error('Error fetching policies:', error); + throw new HttpException( + 'Failed to fetch policies from Saman API', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async fetchInstallments(policyId: number): Promise { + try { + this.logger.log(`Fetching installments for policy ID: ${policyId}`); + + const response = await this.withAuthorizedRequest((token) => + this.axiosInstance.get( + '/Core/policyholder/List-Of-Installments', + { + params: { PolicyId: policyId }, + headers: { Authorization: `Bearer ${token}` }, + }, + ), + ); + + if (!response.data) { + throw new Error('Invalid response from Saman API'); + } + + this.logger.log( + `Successfully fetched installments for policy ID: ${policyId}`, + ); + return response.data; + } catch (error) { + this.logger.error( + `Error fetching installments for policy ${policyId}:`, + error, + ); + throw new HttpException( + `Failed to fetch installments for policy ${policyId}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async fetchPoliciesWithInstallments( + nationalCode: string, + ): Promise<{ policies: any[]; installments: any[] }> { + try { + this.logger.log( + `Fetching all policies with installments for national code: ${nationalCode}`, + ); + + const policiesResponse = await this.fetchPolicies(nationalCode); + + const allPolicies = [ + ...policiesResponse.fireInsurancePolicies.map((p) => ({ + ...p, + policyType: 'fire', + })), + ...policiesResponse.carInsurancePolicies.map((p) => ({ + ...p, + policyType: 'car', + })), + ...policiesResponse.healthInsurancePolicies.map((p) => ({ + ...p, + policyType: 'health', + })), + ...policiesResponse.cargoInsurancePolicies.map((p) => ({ + ...p, + policyType: 'cargo', + })), + ...policiesResponse.equipmentInsurancePolicies.map((p) => ({ + ...p, + policyType: 'equipment', + })), + ...policiesResponse.lifeInsurancePolicies.map((p) => ({ + ...p, + policyType: 'life', + })), + ...policiesResponse.travelInsurancePolicies.map((p) => ({ + ...p, + policyType: 'travel', + })), + ...policiesResponse.liabilityInsurancePolicies.map((p) => ({ + ...p, + policyType: 'liability', + })), + ...policiesResponse.personalAccidentInsurancePolicies.map((p) => ({ + ...p, + policyType: 'personalAccident', + })), + ]; + + this.logger.log(`Found ${allPolicies.length} policies`); + + const allInstallments = []; + + for (const policy of allPolicies) { + try { + const installmentsResponse = await this.withAuthorizedRequest( + (authToken) => + this.axiosInstance.get( + '/Core/policyholder/List-Of-Installments', + { + params: { PolicyId: policy.policyId }, + headers: { Authorization: `Bearer ${authToken}` }, + }, + ), + ); + + if ( + installmentsResponse.data?.policyHolderInstallments?.length > 0 + ) { + allInstallments.push( + ...installmentsResponse.data.policyHolderInstallments, + ); + } + } catch (error) { + this.logger.warn( + `Failed to fetch installments for policy ${policy.policyId}, continuing...`, + ); + } + } + + this.logger.log( + `Successfully fetched ${allPolicies.length} policies and ${allInstallments.length} installments using one shared token`, + ); + + return { + policies: allPolicies, + installments: allInstallments, + }; + } catch (error) { + this.logger.error('Error fetching policies with installments:', error); + throw error; + } + } +} diff --git a/src/externals/sms/saman.service.ts b/src/externals/sms/saman.service.ts new file mode 100644 index 0000000..e18c153 --- /dev/null +++ b/src/externals/sms/saman.service.ts @@ -0,0 +1,90 @@ +import { HttpService } from '@nestjs/axios'; +import { + HttpException, + HttpStatus, + Injectable, + OnModuleInit, +} from '@nestjs/common'; +import axios from 'axios'; +import qs from 'qs'; +import { lastValueFrom } from 'rxjs'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; + +@Injectable() +export class SamanSmsService implements OnModuleInit { + private authToken: string; + private apiKeyKavenegar: any; + private serviceUrl: string = process.env.SAMAN_AUTH_URL; + private headers: { + 'Content-Type': 'application/x-www-form-urlencoded'; + }; + constructor(private readonly httpService: HttpService) {} + async onModuleInit() { + let smsResponse = this.httpService.post( + `https://api.kavenegar.com/v1/${process.env.KavenegarApiKey}/sms/send.json?receptor=${'09226187419'}&sender=${process.env.KVN_sender}&message=${'SMS_CONNECTED'}`, + ); + } + + private async samanNotifyLogin() { + try { + let sendRequest = this.httpService.post(this.serviceUrl, { + headers: this.headers, + data: qs.stringify({ + grant_type: process.env.GrantType, + client_id: process.env.ClientId, + client_secret: process.env.ClientSecret, + }), + }); + //@ts-ignore + let response = await lastValueFrom(sendRequest); + return response.data['access_token']; + } catch (err) { + return new HttpException(err.message, err.status); + } + } + + public async notifyService(mobile, message, type) { + try { + let data = JSON.stringify({ + phoneNumber: mobile, + body: message, + type: type, + }); + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.NOTIFY_URL, + headers: { + 'request-id': process.env.NOTIFY_REQUEST_ID, + 'Content-Type': 'application/json', + Authorization: '', //todo + }, + data: data, + }; + const response = await axios.request(axiosConfig); + if (!response.data) + throw new HttpException('notify_unavailable', HttpStatus.BAD_REQUEST); + + return true; //todo + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async smsSender(msg: string, receptor: string) { + try { + let url = `https://api.kavenegar.com/v1/${process.env.KavenegarApiKey}/verify/lookup.json?receptor=${receptor}&token=${msg}&template=${process.env.TEMPLATE}`; + const response = await lastValueFrom(this.httpService.post(url)); + return response.data; + } catch (err) { + console.error('Kavenegar API Error:', err.response?.data || err.message); + + // Extract the error details from Kavenegar's response (if available) + const errorMessage = err.response?.data?.message || err.message; + const errorStatus = err.response?.status || 500; + + throw new HttpException(errorMessage, errorStatus); + } + } +} diff --git a/src/externals/sms/sms.module.ts b/src/externals/sms/sms.module.ts new file mode 100644 index 0000000..642351e --- /dev/null +++ b/src/externals/sms/sms.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { SamanSmsService } from './saman.service'; +import { SmsService } from './sms.service'; + +@Module({ + imports: [DatabaseModule], + providers: [SmsService, SamanSmsService], + exports: [SamanSmsService], +}) +export class SmsModule {} diff --git a/src/externals/sms/sms.service.ts b/src/externals/sms/sms.service.ts new file mode 100644 index 0000000..bc85c27 --- /dev/null +++ b/src/externals/sms/sms.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SmsService {} diff --git a/src/externals/sso/sso.module.ts b/src/externals/sso/sso.module.ts new file mode 100644 index 0000000..79b8bc9 --- /dev/null +++ b/src/externals/sso/sso.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { SsoService } from './sso.service'; + +@Module({ + imports: [DatabaseModule], + providers: [SsoService], + exports: [SsoService], +}) +export class SsoModule {} diff --git a/src/externals/sso/sso.service.ts b/src/externals/sso/sso.service.ts new file mode 100644 index 0000000..fc91f3e --- /dev/null +++ b/src/externals/sso/sso.service.ts @@ -0,0 +1,149 @@ +import { HttpService } from '@nestjs/axios'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import axios from 'axios'; +import { Model } from 'mongoose'; +import { lastValueFrom } from 'rxjs'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { ExternalTokensModel } from 'src/database/model/externalTokens.model'; + +@Injectable() +export class SsoService { + private serviceUrl: string = process.env.SAMAN_AUTH_URL; + private readonly notifyTokenScope = 'notif-sms'; + private headers: { + 'Content-Type': 'application/x-www-form-urlencoded'; + }; + constructor( + @InjectModel(ExternalTokensModel.name) + private readonly externalTokens: Model, + private readonly httpService: HttpService, + ) {} + + private async samanNotifyLogin() { + try { + const now = Math.floor(Date.now() / 1000); // Current time in seconds + const params = new URLSearchParams({ + grant_type: process.env.GrantType, + client_id: process.env.ClientId, + client_secret: process.env.ClientSecret, + }); + + // Find the most recent active token by sorting in descending order + const token = await this.externalTokens + .findOne({ + scope: this.notifyTokenScope, + isActive: true, + }) + .sort({ timestamps: -1 }); // Get the most recent token + // Check if token exists and is not expired + if (token && token.timestamps && token.expiresIn) { + const tokenTimestamp = Number(token.timestamps); + const expiresInSeconds = Number(token.expiresIn); + + const expirationTime = tokenTimestamp + expiresInSeconds; + + // If token is still valid, return it + if (now < expirationTime) { + return token.token; + } + + // If token is expired, mark it as inactive + await this.externalTokens.updateOne( + { _id: token._id }, + { isActive: false }, + ); + } + + // Request new token + const sendRequest = this.httpService.post( + this.serviceUrl, + params.toString(), + { + headers: { + ...this.headers, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + + //@ts-ignore + const response = await lastValueFrom(sendRequest); + + // Create new token record + const newToken = await this.externalTokens.create({ + token: response.data.access_token, + url: this.serviceUrl, + isActive: true, + method: 'access', + tokenType: 'Bearer', + expiresIn: response.data.expires_in, + scope: this.notifyTokenScope, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + timestamps: now, + }); + + return response.data.access_token; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + public async notifyService(mobile, message, type) { + try { + const accessToken = await this.samanNotifyLogin(); + let data = JSON.stringify({ + phoneNumber: mobile, + body: message, + type: type, + }); + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.NOTIFY_URL, + headers: { + 'request-id': process.env.NOTIFY_REQUEST_ID, + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + data: data, + }; + const response = await axios.request(axiosConfig); + if (!response.data) + throw new HttpException('notify_unavailable', HttpStatus.BAD_REQUEST); + + return true; //todo + } catch (err) { + console.error(err); + + // Handle Axios errors + if (err.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + throw new BaseResponseDTO( + err.response.status, + err.response.data.message || 'notify_failed', + null, + ); + } else if (err.request) { + // The request was made but no response was received + throw new BaseResponseDTO( + HttpStatus.SERVICE_UNAVAILABLE, + 'notify_service_unavailable', + null, + ); + } else { + // Something happened in setting up the request that triggered an error + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'internal_server_error', + null, + ); + } + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..abd06ca --- /dev/null +++ b/src/main.ts @@ -0,0 +1,140 @@ +import { join } from 'node:path'; +import { + ValidationPipe, + VersioningType, + VERSION_NEUTRAL, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpAdapterHost, NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { Request, Response, NextFunction } from 'express'; +import * as express from 'express'; +import { AppModule } from './app.module'; +import { SecurityHeadersMiddleware } from './common/middlewares/security-headers.middleware'; +import { SwaggerAuthMiddleware } from './common/middlewares/swagger-auth.middleware'; +import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + bodyParser: false, + }); + const configService = app.get(ConfigService); + const requestBodyLimit = configService.get('REQUEST_BODY_LIMIT', '10mb'); + + app.useBodyParser('json', { limit: requestBodyLimit }); + app.useBodyParser('urlencoded', { + limit: requestBodyLimit, + extended: true, + }); + + const now = new Date(); + console.log('Server started at:', now.toISOString()); + console.log('Local time:', now.toLocaleString()); + // Get allowed hosts from environment variables (no fallback) + const allowedHosts = configService.get('ALLOWED_HOSTS'); + if (!allowedHosts) { + throw new Error('ALLOWED_HOSTS environment variable not configured'); + } + + // Get CORS origins from environment variables (no fallback) + const corsOrigins = configService.get('CORS_ORIGINS'); + if (!corsOrigins) { + throw new Error('CORS_ORIGINS environment variable not configured'); + } + + // Host Validation Middleware + const hostValidationMiddleware = ( + req: Request, + res: Response, + next: NextFunction, + ) => { + const hostHeader = req.headers.host; + if (!hostHeader || !allowedHosts.includes(hostHeader)) { + console.warn(`Invalid Host header attempt: ${hostHeader}`); + return res.status(400).json({ message: 'Invalid Host header' }); + } + next(); + }; + + // Apply middleware + app.use(hostValidationMiddleware); + + // Swagger password protection + const swaggerAuthMiddleware = new SwaggerAuthMiddleware(configService); + app.use(swaggerAuthMiddleware.use.bind(swaggerAuthMiddleware)); + + app.use('/cdn', express.static(join(__dirname, '..', 'public'))); + + // Versioning + app.enableVersioning({ + defaultVersion: VERSION_NEUTRAL, + type: VersioningType.URI, + }); + + // Swagger + const config = new DocumentBuilder() + .setTitle('CHATBOT_V2') + .setDescription('The new version of Ai-based chatbot swagger.') + .setVersion('1.0') + .addServer(`http://${process.env.BASEURL}:${process.env.PORT}`) + .addServer(`${process.env.DEV_SERVER_URL}`) + .addServer(`${process.env.PROD_SERVER_URL}`) + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document); + + // CORS Configuration + app.enableCors({ + origin: (origin, callback) => { + const allowedOrigins = configService.get('CORS_ORIGINS'); + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + exposedHeaders: [], // Explicitly expose no headers + }); + + // Global pipes + app.useGlobalPipes( + new ValidationPipe({ + whitelist: false, + forbidNonWhitelisted: false, + transform: true, + }), + ); + + // Apply global exception filter + const httpAdapter = app.get(HttpAdapterHost); + app.useGlobalFilters(new AllExceptionsFilter(httpAdapter, app)); + + // Security headers + app.getHttpAdapter().getInstance().disable('x-powered-by'); + app.getHttpAdapter().getInstance().disable('keep-alive'); + app.getHttpAdapter().getInstance().set('Connection', 'close'); + app.use((req, res, next) => { + res.removeHeader('Connection'); + res.removeHeader('Keep-Alive'); + next(); + }); + // After app initialization + app.use(new SecurityHeadersMiddleware().use); + + // Serve static files + app.useStaticAssets(join(__dirname, '..', 'public')); + + app.useStaticAssets(join(__dirname, '..', 'uploads'), { + prefix: '/uploads', + }); + + const port = configService.get('PORT', 8585); + await app.listen(port); + // console.log(`Application is running on: http://localhost:${port}`); +} + +bootstrap(); diff --git a/src/policies/policies.controller.ts b/src/policies/policies.controller.ts new file mode 100644 index 0000000..a340909 --- /dev/null +++ b/src/policies/policies.controller.ts @@ -0,0 +1,68 @@ +import { Controller, Post, Get, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { PoliciesService } from './policies.service'; + +@Controller('policies') +@ApiTags('Policies') +export class PoliciesController { + constructor(private readonly policiesService: PoliciesService) {} + + @ApiOperation({ + summary: 'Get user policies with installments', + }) + @ApiBearerAuth() + @Get() + async getUserPolicies(@CurrentIdentity() user: any) { + try { + const data = await this.policiesService.getUserPoliciesWithInstallments( + user._id, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (error) { + return new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Failed to fetch policies', + null, + ); + } + } + + @ApiOperation({ + summary: 'Refresh user policies from Saman API', + }) + @ApiBearerAuth() + @Post('refresh') + async refreshPolicies(@CurrentIdentity() user: any) { + try { + if (!user.nationalCode) { + return new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'National code not found', + null, + ); + } + + const result = + await this.policiesService.fetchAndStorePoliciesWithInstallments( + user._id, + user.nationalCode, + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + message: 'Policies refreshed successfully', + policiesCount: result.policiesCount, + installmentsCount: result.installmentsCount, + }); + } catch (error) { + return new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Failed to refresh policies', + null, + ); + } + } +} + +// Made with Bob diff --git a/src/policies/policies.module.ts b/src/policies/policies.module.ts new file mode 100644 index 0000000..5d63e42 --- /dev/null +++ b/src/policies/policies.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { PoliciesController } from './policies.controller'; +import { PoliciesService } from './policies.service'; +import { PolicyModel, PolicySchema } from 'src/database/model/policy.model'; +import { + InstallmentModel, + InstallmentSchema, +} from 'src/database/model/installment.model'; +import { + UserInsuranceSnapshotModel, + UserInsuranceSnapshotSchema, +} from 'src/database/model/user-insurance-snapshot.model'; +import { SamanInsuranceModule } from 'src/externals/saman-insurance/saman-insurance.module'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: PolicyModel.name, schema: PolicySchema }, + { name: InstallmentModel.name, schema: InstallmentSchema }, + { + name: UserInsuranceSnapshotModel.name, + schema: UserInsuranceSnapshotSchema, + }, + ]), + SamanInsuranceModule, + ], + controllers: [PoliciesController], + providers: [PoliciesService], + exports: [PoliciesService], +}) +export class PoliciesModule {} + +// Made with Bob diff --git a/src/policies/policies.service.ts b/src/policies/policies.service.ts new file mode 100644 index 0000000..74e84d3 --- /dev/null +++ b/src/policies/policies.service.ts @@ -0,0 +1,593 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { PolicyModel } from 'src/database/model/policy.model'; +import { InstallmentModel } from 'src/database/model/installment.model'; +import { UserInsuranceSnapshotModel } from 'src/database/model/user-insurance-snapshot.model'; +import { SamanInsuranceService } from 'src/externals/saman-insurance/saman-insurance.service'; + +const POLICY_TYPE_TO_AI_KEY: Record = { + fire: 'fireInsurancePolicies', + car: 'carInsurancePolicies', + health: 'healthInsurancePolicies', + cargo: 'cargoInsurancePolicies', + equipment: 'equipmentInsurancePolicies', + life: 'lifeInsurancePolicies', + travel: 'travelInsurancePolicies', + liability: 'liabilityInsurancePolicies', + personalAccident: 'personalAccidentInsurancePolicies', +}; + +const AI_POLICY_TYPE_KEYS = Object.values(POLICY_TYPE_TO_AI_KEY); +const DEFAULT_SNAPSHOT_MAX_AGE_HOURS = 24; + +@Injectable() +export class PoliciesService { + private readonly logger = new Logger(PoliciesService.name); + + constructor( + @InjectModel(PolicyModel.name) + private readonly policyModel: Model, + @InjectModel(InstallmentModel.name) + private readonly installmentModel: Model, + @InjectModel(UserInsuranceSnapshotModel.name) + private readonly snapshotModel: Model, + private readonly samanInsuranceService: SamanInsuranceService, + ) {} + + private toObjectId(userId: string | Types.ObjectId): Types.ObjectId { + return typeof userId === 'string' ? new Types.ObjectId(userId) : userId; + } + + private createSyncBatchId(userId: string | Types.ObjectId): string { + return `${userId.toString()}-${Date.now()}`; + } + + private createEmptyInsuranceData(): Record { + return AI_POLICY_TYPE_KEYS.reduce( + (acc, key) => { + acc[key] = []; + return acc; + }, + { errors: {} } as Record, + ); + } + + private createEmptyInstallmentsData(): Record { + return { + policyHolderInstallments: [], + errors: {}, + }; + } + + private getSnapshotMaxAgeMs(): number { + const hours = parseInt( + process.env.INSURANCE_SNAPSHOT_MAX_AGE_HOURS || + String(DEFAULT_SNAPSHOT_MAX_AGE_HOURS), + 10, + ); + return (Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_SNAPSHOT_MAX_AGE_HOURS) * + 60 * + 60 * + 1000; + } + + private isSnapshotStale(syncedAt?: Date): boolean { + if (!syncedAt) { + return true; + } + return Date.now() - new Date(syncedAt).getTime() > this.getSnapshotMaxAgeMs(); + } + + private snapshotToPayload(snapshot: UserInsuranceSnapshotModel): { + user_insurance_data: Record; + user_installments_data: Record; + } { + return { + user_insurance_data: snapshot.user_insurance_data, + user_installments_data: snapshot.user_installments_data, + }; + } + + private canRefreshFromApi(nationalCode?: string): boolean { + return ( + process.env.SSO_ENABLED === 'true' && + Boolean(nationalCode?.trim()) + ); + } + + private async tryRefreshSnapshot( + userId: string | Types.ObjectId, + nationalCode: string, + ): Promise { + try { + await this.fetchAndStorePoliciesWithInstallments(userId, nationalCode); + return true; + } catch (error) { + this.logger.warn( + `Snapshot refresh failed for user ${userId}, using existing data if available`, + error, + ); + return false; + } + } + + /** + * Fetch from Saman API, upsert policies/installments, mark stale records, + * and save an AI-ready snapshot for the user. + */ + async fetchAndStorePoliciesWithInstallments( + userId: string | Types.ObjectId, + nationalCode: string, + ): Promise<{ policiesCount: number; installmentsCount: number }> { + const userObjectId = this.toObjectId(userId); + const syncBatchId = this.createSyncBatchId(userId); + const syncedAt = new Date(); + + try { + this.logger.log( + `Fetching and storing policies for user ${userId}, national code: ${nationalCode}`, + ); + + const { policies, installments } = + await this.samanInsuranceService.fetchPoliciesWithInstallments( + nationalCode, + ); + + let policiesCount = 0; + for (const policy of policies) { + const { + policyType, + policyId, + policyNumber, + insuranceLineName, + insuranceLineCode, + policyHolderName, + insuredName, + policyIssueDate, + policyBeginDate, + policyEndDate, + policyStatus, + policyStatusCode, + uniquePolicyCode, + referrerName, + issuingUnit, + policyHolderCode, + agentCode, + address, + licensePlate, + vehicleType, + ...rest + } = policy; + + await this.policyModel.findOneAndUpdate( + { userId: userObjectId, policyId }, + { + $set: { + userId: userObjectId, + policyId, + policyNumber, + insuranceLineName, + insuranceLineCode, + policyHolderName, + insuredName, + policyIssueDate, + policyBeginDate, + policyEndDate, + policyStatus, + policyStatusCode, + uniquePolicyCode, + referrerName: referrerName || '', + issuingUnit: issuingUnit || '', + policyHolderCode: policyHolderCode || '', + agentCode: agentCode || 0, + policyType, + address: address || '', + licensePlate: licensePlate || '', + vehicleType: vehicleType || '', + additionalData: Object.keys(rest).length > 0 ? rest : undefined, + isActive: true, + syncBatchId, + lastSyncedAt: syncedAt, + lastUpdated: syncedAt, + }, + }, + { upsert: true, new: true }, + ); + policiesCount++; + } + + let installmentsCount = 0; + for (const installment of installments) { + await this.installmentModel.findOneAndUpdate( + { installmentId: installment.installmentId }, + { + $set: { + policyId: installment.policyId, + userId: userObjectId, + installmentId: installment.installmentId, + paymentMethod: installment.paymentMethod || '', + installmentYear: installment.installmentYear || '', + installmentNumber: installment.installmentNumber || 0, + installmentPaymentAmount: installment.installmentPaymentAmount || 0, + remainingAmount: installment.remainingAmount || 0, + installmentDate: installment.installmentDate || '', + installmentPaymentDate: installment.installmentPaymentDate || '', + installmentStatus: installment.installmentStatus || '', + totalInstallments: installment.totalInstallments || 0, + paidInstallments: installment.paidInstallments || 0, + upcomingInstallmentCount: installment.upcomingInstallmentCount || 0, + overdueInstallmentCount: installment.overdueInstallmentCount || 0, + bankId: installment.bankId || '', + totalPremiumCollected: installment.totalPremiumCollected || 0, + isActive: true, + syncBatchId, + lastSyncedAt: syncedAt, + lastUpdated: syncedAt, + }, + }, + { upsert: true, new: true }, + ); + installmentsCount++; + } + + await this.policyModel.updateMany( + { userId: userObjectId, syncBatchId: { $ne: syncBatchId } }, + { $set: { isActive: false } }, + ); + + await this.installmentModel.updateMany( + { userId: userObjectId, syncBatchId: { $ne: syncBatchId } }, + { $set: { isActive: false } }, + ); + + const activePolicies = await this.policyModel + .find({ userId: userObjectId, isActive: true, policyStatusCode: 1 }) + .lean() + .exec(); + + const activePolicyIds = activePolicies.map((p) => p.policyId); + const activeInstallments = activePolicyIds.length + ? await this.installmentModel + .find({ + userId: userObjectId, + isActive: true, + policyId: { $in: activePolicyIds }, + }) + .lean() + .exec() + : []; + + const aiPayload = this.buildAiPayload(activePolicies, activeInstallments); + + await this.snapshotModel.findOneAndUpdate( + { userId: userObjectId }, + { + $set: { + userId: userObjectId, + syncBatchId, + syncedAt, + user_insurance_data: aiPayload.user_insurance_data, + user_installments_data: aiPayload.user_installments_data, + }, + }, + { upsert: true, new: true }, + ); + + this.logger.log( + `Synced ${policiesCount} policies and ${installmentsCount} installments for user ${userId}`, + ); + + return { policiesCount, installmentsCount }; + } catch (error) { + this.logger.error( + `Error fetching and storing policies for user ${userId}:`, + error, + ); + throw error; + } + } + + private buildAiPayload( + activePolicies: PolicyModel[], + installments: InstallmentModel[], + ): { + user_insurance_data: Record; + user_installments_data: Record; + } { + const userInsuranceData = this.createEmptyInsuranceData(); + + for (const policy of activePolicies) { + const aiKey = POLICY_TYPE_TO_AI_KEY[policy.policyType]; + if (!aiKey) { + continue; + } + + const basePolicy = { + policyId: policy.policyId, + policyNumber: policy.policyNumber, + insuranceLineName: policy.insuranceLineName, + policyHolderName: policy.policyHolderName, + policyIssueDate: policy.policyIssueDate, + policyBeginDate: policy.policyBeginDate, + policyEndDate: policy.policyEndDate, + policyStatus: policy.policyStatus, + }; + + let mappedPolicy: Record; + switch (policy.policyType) { + case 'fire': + mappedPolicy = { ...basePolicy, address: policy.address || '' }; + break; + case 'car': + mappedPolicy = { + ...basePolicy, + licensePlate: policy.licensePlate || '', + vehicleType: policy.vehicleType || '', + }; + break; + default: + mappedPolicy = { + ...basePolicy, + ...(policy.additionalData || {}), + }; + break; + } + + (userInsuranceData[aiKey] as Record[]).push( + mappedPolicy, + ); + } + + const policyHolderInstallments = installments.map((inst) => ({ + policyId: inst.policyId, + installmentId: inst.installmentId, + installmentDate: inst.installmentDate || '', + installmentPaymentAmount: inst.installmentPaymentAmount || 0, + installmentStatus: inst.installmentStatus || '', + paymentMethod: inst.paymentMethod || '', + installmentYear: inst.installmentYear || '', + totalInstallments: inst.totalInstallments || 0, + paidInstallments: inst.paidInstallments || 0, + upcomingInstallmentCount: inst.upcomingInstallmentCount || 0, + overdueInstallmentCount: inst.overdueInstallmentCount || 0, + totalPremiumCollected: inst.totalPremiumCollected || 0, + installmentNumber: inst.installmentNumber || 0, + remainingAmount: inst.remainingAmount || 0, + installmentPaymentDate: inst.installmentPaymentDate || '', + })); + + return { + user_insurance_data: userInsuranceData, + user_installments_data: { + policyHolderInstallments, + errors: {}, + }, + }; + } + + /** + * Read the latest AI-ready snapshot for a user. + * Refreshes from Saman API when snapshot is missing or older than 24h. + * On refresh failure, returns the last known snapshot if available. + */ + async getUserDataForAi( + userId: string | Types.ObjectId, + nationalCode?: string, + ): Promise<{ + user_insurance_data: Record; + user_installments_data: Record; + }> { + const userObjectId = this.toObjectId(userId); + + try { + const snapshot = await this.snapshotModel + .findOne({ userId: userObjectId }) + .lean() + .exec(); + + const needsRefresh = + !snapshot || this.isSnapshotStale(snapshot.syncedAt); + + if (needsRefresh && this.canRefreshFromApi(nationalCode)) { + const refreshed = await this.tryRefreshSnapshot( + userId, + nationalCode.trim(), + ); + + if (refreshed) { + const freshSnapshot = await this.snapshotModel + .findOne({ userId: userObjectId }) + .lean() + .exec(); + + if (freshSnapshot) { + return this.snapshotToPayload(freshSnapshot); + } + } + + if (snapshot) { + return this.snapshotToPayload(snapshot); + } + } + + if (snapshot) { + return this.snapshotToPayload(snapshot); + } + + return this.buildAiPayloadFromDb(userObjectId); + } catch (error) { + this.logger.error( + `Error reading AI payload for user ${userId}:`, + error, + ); + return { + user_insurance_data: { + ...this.createEmptyInsuranceData(), + errors: { fetchError: 'Failed to load user insurance data' }, + }, + user_installments_data: { + ...this.createEmptyInstallmentsData(), + errors: { fetchError: 'Failed to load user installments data' }, + }, + }; + } + } + + private async buildAiPayloadFromDb(userObjectId: Types.ObjectId): Promise<{ + user_insurance_data: Record; + user_installments_data: Record; + }> { + const activePolicies = await this.policyModel + .find({ userId: userObjectId, isActive: true, policyStatusCode: 1 }) + .lean() + .exec(); + + if (!activePolicies.length) { + return { + user_insurance_data: this.createEmptyInsuranceData(), + user_installments_data: this.createEmptyInstallmentsData(), + }; + } + + const activePolicyIds = activePolicies.map((p) => p.policyId); + const installments = await this.installmentModel + .find({ + userId: userObjectId, + isActive: true, + policyId: { $in: activePolicyIds }, + }) + .lean() + .exec(); + + return this.buildAiPayload(activePolicies, installments); + } + + async getUserPolicies(userId: string | Types.ObjectId): Promise { + const userObjectId = this.toObjectId(userId); + + return await this.policyModel + .find({ userId: userObjectId, isActive: true }) + .sort({ lastUpdated: -1 }) + .lean() + .exec(); + } + + async getPolicyInstallments(policyId: number): Promise { + return await this.installmentModel + .find({ policyId, isActive: true }) + .sort({ installmentDate: -1 }) + .lean() + .exec(); + } + + async getUserPoliciesWithInstallments( + userId: string | Types.ObjectId, + ): Promise { + try { + const userObjectId = this.toObjectId(userId); + + const policies = await this.policyModel + .find({ userId: userObjectId, isActive: true }) + .lean() + .exec(); + + if (!policies.length) { + return { + total_policies: 0, + active_policies: 0, + policies: [], + }; + } + + const policyIds = policies.map((p) => p.policyId); + const allInstallments = await this.installmentModel + .find({ userId: userObjectId, isActive: true, policyId: { $in: policyIds } }) + .lean() + .exec(); + + const installmentsByPolicy = allInstallments.reduce((acc, inst) => { + if (!acc[inst.policyId]) { + acc[inst.policyId] = []; + } + acc[inst.policyId].push(inst); + return acc; + }, {}); + + const policiesWithInstallments = policies.map((policy) => { + const policyInstallments = installmentsByPolicy[policy.policyId] || []; + + policyInstallments.sort((a, b) => { + if (a.installmentYear !== b.installmentYear) { + return b.installmentYear.localeCompare(a.installmentYear); + } + return (b.installmentNumber || 0) - (a.installmentNumber || 0); + }); + + const firstInstallment = policyInstallments[0]; + + return { + policy_info: { + policyId: policy.policyId, + policyNumber: policy.policyNumber, + insuranceLineName: policy.insuranceLineName, + insuranceLineCode: policy.insuranceLineCode, + policyHolderName: policy.policyHolderName, + insuredName: policy.insuredName, + policyIssueDate: policy.policyIssueDate, + policyBeginDate: policy.policyBeginDate, + policyEndDate: policy.policyEndDate, + policyStatus: policy.policyStatus, + policyStatusCode: policy.policyStatusCode, + uniquePolicyCode: policy.uniquePolicyCode, + referrerName: policy.referrerName, + issuingUnit: policy.issuingUnit, + policyType: policy.policyType, + }, + installments: { + total_installments: firstInstallment?.totalInstallments || 0, + paid_installments: firstInstallment?.paidInstallments || 0, + upcoming_installments: firstInstallment?.upcomingInstallmentCount || 0, + overdue_installments: firstInstallment?.overdueInstallmentCount || 0, + total_premium_collected: firstInstallment?.totalPremiumCollected || 0, + installment_details: policyInstallments.map((inst) => ({ + installmentId: inst.installmentId, + installmentYear: inst.installmentYear, + installmentNumber: inst.installmentNumber, + installmentPaymentAmount: inst.installmentPaymentAmount, + remainingAmount: inst.remainingAmount, + installmentDate: inst.installmentDate, + installmentPaymentDate: inst.installmentPaymentDate, + installmentStatus: inst.installmentStatus, + paymentMethod: inst.paymentMethod, + })), + }, + }; + }); + + const activePolicies = policies.filter((p) => p.policyStatusCode === 1); + + return { + total_policies: policies.length, + active_policies: activePolicies.length, + policies: policiesWithInstallments, + }; + } catch (error) { + this.logger.error( + `Error getting policies with installments for user ${userId}:`, + error, + ); + throw error; + } + } + + async deleteUserPolicies(userId: string | Types.ObjectId): Promise { + const userObjectId = this.toObjectId(userId); + + await this.policyModel.deleteMany({ userId: userObjectId }); + await this.installmentModel.deleteMany({ userId: userObjectId }); + await this.snapshotModel.deleteOne({ userId: userObjectId }); + + this.logger.log(`Deleted all policies and installments for user ${userId}`); + } +} diff --git a/src/reports/dto/create-report.dto.ts b/src/reports/dto/create-report.dto.ts new file mode 100644 index 0000000..71a2e90 --- /dev/null +++ b/src/reports/dto/create-report.dto.ts @@ -0,0 +1 @@ +export class CreateReportDto {} diff --git a/src/reports/dto/update-report.dto.ts b/src/reports/dto/update-report.dto.ts new file mode 100644 index 0000000..95b48d8 --- /dev/null +++ b/src/reports/dto/update-report.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateReportDto } from './create-report.dto'; + +export class UpdateReportDto extends PartialType(CreateReportDto) {} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts new file mode 100644 index 0000000..1c45230 --- /dev/null +++ b/src/reports/reports.controller.ts @@ -0,0 +1,366 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + Res, +} from '@nestjs/common'; +import { Response } from 'express'; +import { CreateReportDto } from './dto/create-report.dto'; +import { UpdateReportDto } from './dto/update-report.dto'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { ReportsService } from './reports.service'; +import { SkipAdminRateLimit } from 'src/common/decorators/skip-admin-rate-limit.decorator'; // Import the new decorator + +@ApiBearerAuth() +@Permissions(Permission.ReportsView) +@UseGuards(AdminGuard) +@SkipAdminRateLimit() // Apply the decorator to the entire controller +@ApiTags('reports module') +@Controller('reports') +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get('/bot/total-asked') + @ApiOperation({ + summary: 'count of total questions users asked', + }) + totalAsked() { + return this.reportsService.totalAsked(); + } + + @Get('/bot/bot-response-total-report') + @ApiOperation({ + summary: 'bot response report to users asked questions', + }) + @ApiQuery({ + name: 'date', + required: true, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', + }) + totalBotAnswering(@Query('date') dateRange: string) { + return this.reportsService.totalBotAnswering(dateRange); + } + + @Get('/bot/connect-to-expert-rate') + @ApiOperation({ + summary: 'user connection to expert rate.', + }) + @ApiQuery({ + name: 'date', + required: true, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', + }) + connectToExpertRate(@Query('date') dateRange: string) { + return this.reportsService.connectToExpertRate(dateRange); + } + + @Get('/user/top-ten-questions') + @ApiOperation({ + summary: 'user report | last 10 questions', + }) + topTenQuestions() { + return this.reportsService.topTenQuestions(); + } + + @Get('/user/top-ten-users') + @ApiOperation({ + summary: 'user report | top 10 users', + }) + topTenUsers() { + return this.reportsService.topTenUsers(); + } + + @Get('/user/bot-answer-react') + @ApiOperation({ + summary: + 'user report | report of bot answers to user questions (liked , disliked , none action)', + }) + @ApiQuery({ + name: 'date', + required: true, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ["all", "bot", "online"], + description: 'Filter sessions by expert status: all, bot-handled, or online-expert-handled', + example: 'all', + }) + totalBotAnswerRate(@Query('date') dateRange: string, @Query('status') status?: "all" | "bot" | "online") { + return this.reportsService.botAnswerReact(dateRange, status); + } + + @Get('/user/expert-answer-rate') + @ApiOperation({ + summary: + 'user report | report of user rates to the expert answers , stars from 1 to 5', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + expertAnswerRate(@Query('date') dateRange?: string) { + let startDate: string | undefined; + let endDate: string | undefined; + + if (dateRange) { + [startDate, endDate] = dateRange.split('-'); + } + return this.reportsService.expertAnswerRate(startDate, endDate); + } + + @Get('/expert/total-expert-answering') + @ApiOperation({ + summary: 'user report | totalAnswering', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + totalAnswering( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + return this.reportsService.totalAnswering(dateRange, normalizedExperts); + } + + @Get('/bot/total-react') + @ApiOperation({ + summary: 'Bot responses report: total bot messages, rated messages (likes/dislikes), and reaction counts', + description: 'Returns statistics for bot responses in bot-only sessions (sessions not connected to expert)', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', + }) + totalBotAnswersReact(@Query('date') dateRange?: string) { + return this.reportsService.totalBotAnswersReact(dateRange); + } + + @Get('/bot/total-connect-to-expert-rate') + @ApiOperation({ + summary: 'total user connection to expert rate without date filter.', + }) + totalConnectToExpertRate() { + return this.reportsService.totalConnectToExpertRate(); + } + + @Get('/user/activeUsers') + @ApiOperation({ + summary: 'total active users.', + }) + activeUsers() { + return this.reportsService.activeUsers(); + } + + @Get('/user/total-sessions') + @ApiOperation({ + summary: 'total sessions count with optional date filter', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31"). If not provided, returns total count of all sessions.', + example: '1404/01/01-1404/01/31', + }) + totalSessions(@Query('date') dateRange?: string) { + return this.reportsService.totalSessions(dateRange); + } + + @Permissions(Permission.ReportsExpertTiming) + @Get('/expert/avg-expert-answering') + @ApiOperation({ + summary: 'user report | average time experts took to answer the questions', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + avgAnswering( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + return this.reportsService.avgAnswering(dateRange, normalizedExperts); + } + + @Permissions(Permission.ReportsExpertTiming) + @Get('/expert/avg-waiting-time') + @ApiOperation({ + summary: 'user report | average waiting time experts took to answer the questions', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + avgWaitingTime( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + return this.reportsService.avgWaitingTime(dateRange, normalizedExperts); + } + + @Get('/expert/expertResponseRate') + @ApiOperation({ + summary: 'Expert response rate, average response time, and average user waiting time.', + }) + expertResponseRate() { + return this.reportsService.expertMetrics(); + } + + @Get('/expert/export-excel') + @ApiOperation({ + summary: 'Export expert reports to Excel with comprehensive data and charts', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31"). Applies to answering and waiting time reports only.', + example: '1404/01/01-1404/01/31', + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + @ApiQuery({ + name: 'rate', + required: false, + type: Number, + description: + 'Filter expert list by average rating (1-5). Returns experts with average rating equal to the specified value.', + example: 4, + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'Search expert list by mobile or other fields', + example: '09999985840', + }) + async exportExpertReports( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + @Query('rate') rate?: string, + @Query('search') search?: string, + @Res() res?: Response, + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + + const buffer = await this.reportsService.exportExpertReportsToExcel( + dateRange, + normalizedExperts, + rate, + search, + ); + + const filename = `expert-reports-${new Date().getTime()}.xlsx`; + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } +} diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts new file mode 100644 index 0000000..a12a283 --- /dev/null +++ b/src/reports/reports.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { UserManagementModule } from 'src/user-management/user-management.module'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; + +@Module({ + imports: [DatabaseModule, UserManagementModule], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts new file mode 100644 index 0000000..ec2dee2 --- /dev/null +++ b/src/reports/reports.service.ts @@ -0,0 +1,1631 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { UserManagementService } from 'src/user-management/user-management.service'; +import * as ExcelJS from 'exceljs'; + +@Injectable() +export class ReportsService { + constructor( + @InjectModel(SessionModel.name) + private readonly sessionModel: Model, + @InjectModel(UserModel.name) private readonly userModel: Model, + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogsModel: Model, + private readonly userManagementService: UserManagementService, + ) {} + async totalBotAnswering(dateRange: string) { + try { + // Split the date range + const [startDate, endDate] = dateRange.split('-'); + // Get date segments + const dateSegments = TimeHelper.divideDateRange(startDate, endDate); + + // Initialize data structure + const data = { + labels: dateSegments.map((segment) => ({ + start: segment.start, + end: segment.end, + })), + data: await Promise.all( + dateSegments.map(async (segment) => { + // Convert dates to ISO for MongoDB query + const startISO = TimeHelper.jalaliToISO(segment.start); + const endISO = TimeHelper.jalaliToISO(segment.end); + const startDate = new Date(startISO); + const endDate = new Date(endISO); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + // Build query for sessions created in date range + // Note: We count bot messages from ALL sessions, not just bot-only sessions, + // because sessions that connect to expert may still have bot messages that were rated + const query: any = { + createdISO: { + $gte: startDate, + $lte: endDate, + }, + }; + + // Query sessions with messages field, use lean for performance + const sessions = await this.sessionModel + .find(query) + .select('messages') + .lean() + .exec(); + + // Count reactions - count all bot messages from sessions created in date range + let positiveQuestions = 0; + let negativeQuestions = 0; + let totalBotMessages = 0; + + sessions.forEach((session) => { + session.messages?.forEach((message) => { + // Count all bot messages (regardless of message date) + if (message.sender === 'Bot') { + totalBotMessages++; + // Normalize react value: trim whitespace and convert to lowercase + const react = message.react ? String(message.react).trim().toLowerCase() : null; + if (react === 'like') { + positiveQuestions++; + } else if (react === 'dislike') { + negativeQuestions++; + } + } + }); + }); + + return { + start: segment.start, + end: segment.end, + positiveQuestions, + negativeQuestions, + totalBotMessages, // Add total bot messages to response + }; + }), + ), + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.error('Error in totalBotAnswering:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async connectToExpertRate(dateRange: string) { + try { + // Split the date range + const [startDate, endDate] = dateRange.split('-'); + + // Get date segments + const dateSegments = TimeHelper.divideDateRange(startDate, endDate); + + // Initialize data structure + const data = { + labels: dateSegments.map((segment) => ({ + start: segment.start, + end: segment.end, + })), + data: await Promise.all( + dateSegments.map(async (segment) => { + // Convert dates to ISO for MongoDB query + const startISO = TimeHelper.jalaliToISO(segment.start); + const endISO = TimeHelper.jalaliToISO(segment.end); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + // Query sessions within date range with additional fields for validation + const sessions = await this.sessionModel + .find({ + createdISO: { + $gte: startDateObj, + $lte: endDateObj, + }, + }) + .select('connectedToExpert expert onlineStartDate') + .lean() + .exec(); + + // Calculate expert connection rates + // Use simple, reliable counting logic + const totalSessions = sessions.length; + + // Count sessions where connectedToExpert is explicitly true + // Using strict equality to ensure we only count actual true values + const connectedToExpert = sessions.filter( + (session) => session.connectedToExpert === true, + ).length; + + // Calculate notConnectedToExpert as the difference + // This is more reliable than filtering, as it ensures total = connected + notConnected + // This handles all edge cases: false, null, undefined, or missing field + const notConnectedToExpert = totalSessions - connectedToExpert; + + // Data integrity validation: Check for suspicious patterns + // Sessions with connectedToExpert: true should have expert field and onlineStartDate + const suspiciousSessions = sessions.filter( + (session) => + session.connectedToExpert === true && + (!session.expert || !session.onlineStartDate) + ).length; + + // Debug logging for production troubleshooting + // Only log if there are unexpected value types (null, undefined, or missing fields) + if (sessions.length > 0) { + const valueTypes = { + true: sessions.filter(s => s.connectedToExpert === true).length, + false: sessions.filter(s => s.connectedToExpert === false).length, + null: sessions.filter(s => s.connectedToExpert === null).length, + undefined: sessions.filter(s => s.connectedToExpert === undefined).length, + missing: sessions.filter(s => !('connectedToExpert' in s)).length, + }; + + // Only log if there are unexpected value types (for debugging) + if (valueTypes.null > 0 || valueTypes.undefined > 0 || valueTypes.missing > 0) { + console.warn(`[connectToExpertRate] Unexpected value types for segment ${segment.start}-${segment.end}:`, valueTypes); + } + + // Log suspicious sessions (connectedToExpert: true but missing required fields) + if (suspiciousSessions > 0) { + console.warn(`[connectToExpertRate] ⚠️ Data integrity issue detected for segment ${segment.start}-${segment.end}: ${suspiciousSessions} sessions marked as connectedToExpert: true but missing expert field or onlineStartDate`); + } + } + + // Validate and log if there's any unexpected data (shouldn't happen with this logic) + if (totalSessions !== connectedToExpert + notConnectedToExpert) { + console.error(`[connectToExpertRate] Critical data inconsistency detected for segment ${segment.start}-${segment.end}: total=${totalSessions}, connected=${connectedToExpert}, notConnected=${notConnectedToExpert}`); + } + + // Log warning if connection rate seems unusually high (potential data issue) + const connectionRate = totalSessions > 0 ? (connectedToExpert / totalSessions) * 100 : 0; + if (connectionRate > 60 && totalSessions > 10) { + console.warn(`[connectToExpertRate] ⚠️ Unusually high connection rate (${connectionRate.toFixed(1)}%) for segment ${segment.start}-${segment.end}. This may indicate a data integrity issue.`); + } + + const result = { + start: segment.start, + end: segment.end, + totalSessions, + connectedToExpert: connectedToExpert, + notConnectedToExpert: notConnectedToExpert, + connectionRate: + totalSessions > 0 + ? Math.round((connectedToExpert / totalSessions) * 100) + : 0, + // Add data integrity flags + dataIntegrityWarning: suspiciousSessions > 0 || connectionRate > 60, + suspiciousSessionsCount: suspiciousSessions, + }; + + return result; + }), + ), + }; + + // Calculate summary totals across all segments for verification + const summary = { + totalSessions: data.data.reduce((sum, segment) => sum + segment.totalSessions, 0), + totalConnectedToExpert: data.data.reduce((sum, segment) => sum + segment.connectedToExpert, 0), + totalNotConnectedToExpert: data.data.reduce((sum, segment) => sum + segment.notConnectedToExpert, 0), + overallConnectionRate: 0, + totalSuspiciousSessions: data.data.reduce((sum, segment) => sum + (segment.suspiciousSessionsCount || 0), 0), + }; + summary.overallConnectionRate = summary.totalSessions > 0 + ? Math.round((summary.totalConnectedToExpert / summary.totalSessions) * 100) + : 0; + + // Validate summary totals match + if (summary.totalSessions !== summary.totalConnectedToExpert + summary.totalNotConnectedToExpert) { + console.error('[connectToExpertRate] Summary validation failed:', summary); + } + + // Log overall data integrity warning if connection rate is unusually high + if (summary.overallConnectionRate > 60 && summary.totalSessions > 50) { + console.warn(`[connectToExpertRate] ⚠️ Overall connection rate is ${summary.overallConnectionRate}% which seems unusually high. This may indicate a data integrity issue.`); + console.warn(`[connectToExpertRate] Summary: ${summary.totalSessions} total sessions, ${summary.totalConnectedToExpert} connected, ${summary.totalNotConnectedToExpert} not connected`); + console.warn(`[connectToExpertRate] Suspicious sessions (connectedToExpert: true but missing expert/onlineStartDate): ${summary.totalSuspiciousSessions}`); + } + + // Add summary to response for easy verification + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + ...data, + summary, + }); + } catch (err) { + console.error('Error in connectToExpertRate:', err); + // If error is already a BaseResponseDTO, re-throw it + if (err instanceof BaseResponseDTO) { + throw err; + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalAsked() { + try { + const sessions = await this.sessionModel + .find({}) + .select('messages') + .lean() + .exec(); + + const totalAsked = sessions.reduce((total, session) => { + return ( + total + (session.messages?.filter((msg) => msg.sender === 'User').length || 0) + ); + }, 0); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + count: totalAsked, + }); + } catch (err) { + console.error('Error in totalAsked:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async avgAnswering(dateRange?: string, experts?: string[]) { + try { + // Build expert list (either provided or all existing experts) + // Only include active experts (isActive: true) to avoid ruining the results + let expertsList: { username: string; name?: string; family?: string }[]; + if (experts && experts.length > 0) { + // First, check all requested experts (both active and inactive) to validate + const allRequestedExperts = await this.adminModel + .find({ role: 'expert', username: { $in: experts } }) + .lean(); + + // Check for inactive experts + const inactiveExperts = allRequestedExperts + .filter((a) => !a.isActive) + .map((a) => a.username); + + // If any inactive experts are found, return an error + if (inactiveExperts.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Cannot calculate average for inactive experts: ${inactiveExperts.join(', ')}`, + { inactiveExperts }, + ); + } + + // Check for experts that don't exist in database + const foundUsernames = new Set(allRequestedExperts.map((a) => a.username)); + const notFound = experts.filter((u) => !foundUsernames.has(u)); + + if (notFound.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Experts not found: ${notFound.join(', ')}`, + { notFound }, + ); + } + + // All experts are active and exist, proceed with active experts only + const admins = await this.adminModel + .find({ role: 'expert', username: { $in: experts }, isActive: true }) + .lean(); + expertsList = admins.map((a) => ({ username: a.username, name: a.name, family: a.family })); + } else { + const allExperts = await this.adminModel.find({ role: 'expert', isActive: true }).lean(); + expertsList = allExperts.map((e) => ({ username: e.username, name: e.name, family: e.family })); + } + + const result = await Promise.all( + expertsList.map(async (expert) => { + // Build query + const query: any = { + expert: expert.username, + connectedToExpert: true, + onlineChatClosed: true, + onlineStartDate: { $exists: true, $ne: null }, + onlineEndDate: { $exists: true, $ne: null }, + }; + + // Add date filter if provided + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + query.createdISO = { + $gte: new Date(startISO), + $lte: new Date(endISO), + }; + } + + // Find all sessions for this expert + const sessions = await this.sessionModel.find(query); + + // Calculate average response time + let totalDuration = 0; + sessions.forEach((session) => { + const duration = Math.abs( + session.onlineEndDate.getTime() - + session.onlineStartDate.getTime(), + ); + totalDuration += duration; + }); + + const averageResponseTime = + sessions.length > 0 + ? Math.round((totalDuration / sessions.length / 60000) * 100) / + 100 // Convert to minutes and round to 2 decimals + : 0; + + return { + averageResponseTime, + firstName: expert.name || 'Unknown', + lastName: expert.family || 'Expert', + averageResponseTimeUnit: 'minute', + totalSessions: sessions.length, + }; + }), + ); + + // Sort by average response time + result.sort((a, b) => a.averageResponseTime - b.averageResponseTime); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', result); + } catch (err) { + console.error('Error in avgAnswering:', err); + // If error is already a BaseResponseDTO, re-throw it + if (err instanceof BaseResponseDTO) { + throw err; + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async avgWaitingTime(dateRange?: string, experts?: string[]) { + try { + const query: any = {}; + + // Date filter (optional) + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + // Set bounds to cover full days + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + query.createdISO = { $gte: startDateObj, $lte: endDateObj }; + } + + // Experts filter (optional) + if (experts && experts.length > 0) { + query.expert = { $in: experts }; + } + + // If experts not specified, compute for all experts + // Only include active experts (isActive: true) to avoid ruining the results + let expertsList: { username: string; name?: string; family?: string }[]; + if (!experts || experts.length === 0) { + const allExperts = await this.adminModel.find({ role: 'expert', isActive: true }).lean(); + expertsList = allExperts.map((e) => ({ + username: e.username, + name: e.name, + family: e.family, + })); + } else { + // First, check all requested experts (both active and inactive) to validate + const allRequestedExperts = await this.adminModel + .find({ role: 'expert', username: { $in: experts } }) + .lean(); + + // Check for inactive experts + const inactiveExperts = allRequestedExperts + .filter((a) => !a.isActive) + .map((a) => a.username); + + // If any inactive experts are found, return an error + if (inactiveExperts.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Cannot calculate average waiting time for inactive experts: ${inactiveExperts.join(', ')}`, + { inactiveExperts }, + ); + } + + // Check for experts that don't exist in database + const foundUsernames = new Set(allRequestedExperts.map((a) => a.username)); + const notFound = experts.filter((u) => !foundUsernames.has(u)); + + if (notFound.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Experts not found: ${notFound.join(', ')}`, + { notFound }, + ); + } + + // All experts are active and exist, proceed with active experts only + const admins = await this.adminModel + .find({ role: 'expert', username: { $in: experts }, isActive: true }) + .lean(); + expertsList = admins.map((a) => ({ username: a.username, name: a.name, family: a.family })); + } + + const result = await Promise.all( + expertsList.map(async (exp) => { + const sessions = await this.sessionModel.find({ + ...query, + expert: exp.username, + }); + + let totalWaitingTime = 0; + let sessionsWithExpertMessage = 0; + + sessions.forEach((session) => { + if (!session.onlineStartDate) return; + const onlineStartDate = new Date(session.onlineStartDate); + + const firstExpertMessage = session.messages.find( + (message) => message.sender?.toLowerCase() === 'expert', + ); + + if (firstExpertMessage?.createdISO) { + const expertMessageDate = new Date(firstExpertMessage.createdISO); + const waitingTimeInSeconds = + (expertMessageDate.getTime() - onlineStartDate.getTime()) / 1000; + if (waitingTimeInSeconds >= 0) { + totalWaitingTime += waitingTimeInSeconds; + sessionsWithExpertMessage++; + } + } + }); + + const avgWaitingTime = + sessionsWithExpertMessage > 0 + ? totalWaitingTime / sessionsWithExpertMessage + : 0; + + return { + firstName: exp.name || 'Unknown', + lastName: exp.family || 'Expert', + avgWaitingTime: parseFloat(avgWaitingTime.toFixed(2)), + }; + }), + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', result); + } catch (err) { + console.error('Error in avgWaitingTime:', err); + // If error is already a BaseResponseDTO, re-throw it + if (err instanceof BaseResponseDTO) { + throw err; + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalAnswering(dateRange?: string, experts?: string[]) { + try { + const query: any = { + connectedToExpert: true, + }; + + // Date filter (optional) + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + query.createdISO = { $gte: startDateObj, $lte: endDateObj }; + } + + // Build expert list (either provided or all existing experts) + // Include email since reassigned_logs uses email in the 'from' field + let expertsList: { username: string; email?: string; name?: string; family?: string }[]; + if (experts && experts.length > 0) { + const admins = await this.adminModel + .find({ role: 'expert', username: { $in: experts } }) + .lean(); + const found = new Set(admins.map((a) => a.username)); + const missing = experts + .filter((u) => !found.has(u)) + .map((u) => ({ username: u })); + expertsList = [ + ...admins.map((a) => ({ username: a.username, email: a.email, name: a.name, family: a.family })), + ...missing, + ]; + } else { + const allExperts = await this.adminModel + .find({ role: 'expert' }) + .lean(); + expertsList = allExperts.map((e) => ({ username: e.username, email: e.email, name: e.name, family: e.family })); + } + + // Get sessionIds in date range for filtering reassigned_logs (if dateRange is provided) + let sessionIdsInDateRange: string[] = []; + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const sessionsInRange = await this.sessionModel + .find({ + createdISO: { $gte: startDateObj, $lte: endDateObj }, + }) + .select('_id') + .lean() + .exec(); + sessionIdsInDateRange = sessionsInRange.map(s => s._id.toString()); + } + + const result = await Promise.all( + expertsList.map(async (exp) => { + // Use expert's email to find sessions (expert field stores email) + // This matches exactly how getProfile function does it + const expertEmail = exp.email; + + // Skip experts without email (they shouldn't exist, but handle gracefully) + if (!expertEmail) { + return { + expert: exp.username, + firstName: exp.name || 'Unknown', + lastName: exp.family || 'Expert', + answered: 0, + notAnswered: 0, + total: 0, + fastest: 0, + }; + } + + // Build session query - match EXACTLY the query from getProfile function + // Calculate total answered: sessions where expert actually connected (connectedToExpert === true) + // and the expert field matches this expert's email + const sessionQuery: any = { + expert: expertEmail, + connectedToExpert: true, + onlineStartDate: { $exists: true }, + }; + + // Apply date filter if provided + if (dateRange) { + sessionQuery.createdISO = query.createdISO; + } + + // This matches the exact logic from getProfile function + const answeredSessions = await this.sessionModel.find(sessionQuery); + + // Calculate total answered (sessions where expert connected) + // This matches exactly: totalAnswered = answeredSessions.length + const answered = answeredSessions.length; + + // Calculate fastest response time (only from sessions with both start and end dates) + let fastestSeconds = Infinity; + answeredSessions.forEach((session) => { + if (session.onlineStartDate && session.onlineEndDate) { + const durationSec = + (new Date(session.onlineEndDate).getTime() - new Date(session.onlineStartDate).getTime()) / 1000; + if (durationSec >= 0 && durationSec < fastestSeconds) { + fastestSeconds = durationSec; + } + } + }); + + // Calculate total not answered: count from reassigned_logs where this expert missed the chat + // (reassigned from them, meaning they were online but didn't answer) + // This matches exactly the query from getProfile function: { from: expertEmail } + const notAnsweredQuery: any = { + from: expertEmail, + }; + + // If dateRange is provided, filter by sessionIds in that range + if (dateRange && sessionIdsInDateRange.length > 0) { + notAnsweredQuery.sessionId = { + $in: sessionIdsInDateRange.map(id => new Types.ObjectId(id)), + }; + } else if (dateRange && sessionIdsInDateRange.length === 0) { + // No sessions in date range, so no reassignments either + notAnsweredQuery.sessionId = { $in: [] }; // Empty array means no matches + } + + const notAnswered = await this.reassignedLogsModel.countDocuments(notAnsweredQuery); + + const fastest = fastestSeconds === Infinity ? 0 : parseFloat(fastestSeconds.toFixed(2)); + + return { + expert: exp.username, + firstName: exp.name || 'Unknown', + lastName: exp.family || 'Expert', + answered, + notAnswered, + total: answered + notAnswered, + fastest, + }; + }), + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', result); + } catch (err) { + console.error('Error in totalAnswering:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async expertAnswerRate( + startDate?: string, + endDate?: string, + ) { + try { + const query: any = { + connectedToExpert: true, + onlineChatClosed: true, + }; + + if (startDate && endDate) { + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + query['createdISO'] = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + // Find all sessions that are connected to expert and closed + // (including both rated and non-rated sessions) + // Use lean() for better performance since we only need expertRate field + const sessions = await this.sessionModel + .find(query) + .select('expertRate') + .lean() + .exec(); + + // Initialize counts for each rate + const rateCounts = { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }; + + // Filter valid rates and count distribution + const validRates: number[] = []; + let nonRatedCount = 0; + + sessions.forEach((session) => { + const rate = session.expertRate; + if (rate != null && Number.isInteger(rate) && rate >= 1 && rate <= 5) { + validRates.push(rate); + rateCounts[rate as keyof typeof rateCounts]++; + } else { + // Count sessions without rating (null, undefined, or invalid) + nonRatedCount++; + } + }); + + // Calculate average rating (only from rated sessions) + const totalRatedSessions = validRates.length; + const sumOfRates = validRates.reduce((sum, rate) => sum + rate, 0); + const averageRating = totalRatedSessions > 0 + ? Math.round((sumOfRates / totalRatedSessions) * 100) / 100 // Round to 2 decimal places + : 0; + + // Total sessions includes both rated and non-rated + const totalSessions = totalRatedSessions + nonRatedCount; + + // Calculate weighted total: 1*count1 + 2*count2 + 3*count3 + 4*count4 + 5*count5 + // All ratings (1-5) are considered "likes" but weighted by their star value + const totalWeightedRates = + 1 * rateCounts[1] + + 2 * rateCounts[2] + + 3 * rateCounts[3] + + 4 * rateCounts[4] + + 5 * rateCounts[5]; + + // Maximum possible weighted happiness: if all rated sessions were 5 stars + const maxPossibleWeightedRates = totalRatedSessions * 5; + + // Calculate percentage: actual weighted happiness / maximum possible weighted happiness + // This shows what percentage of maximum possible "happiness" was achieved + const ratingPercentage = maxPossibleWeightedRates > 0 + ? Math.round((totalWeightedRates / maxPossibleWeightedRates) * 10000) / 100 // Round to 2 decimal places + : 0; + + // Transform the counts into the required format + const distribution = Object.entries(rateCounts).map(([rate, count]) => ({ + rate: parseInt(rate), + count, + })); + + const data = { + averageRating, + ratingPercentage, // Percentage of weighted happiness achieved (actual weighted rates / max possible if all were 5 stars) (0-100) + totalSessions, // Total sessions (rated + non-rated) + totalRatedSessions, // Only rated sessions + nonRatedCount, // Non-rated sessions count + distribution, + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.error('Error in expertAnswerRate:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + totalBotAnswerRate(dateRange: string) { + const data = [ + { + persianLabel: 'سوال نامفهوم', + englishLabel: 'unclear', + value: 342, + }, + { + persianLabel: 'پاسخ با بازخورد مثبت', + englishLabel: 'answered', + value: 418, + }, + { + persianLabel: 'پاسخ بدون بازخورد', + englishLabel: 'noFeedback', + value: 1367, + }, + { + persianLabel: 'اتصال به کارشناس', + englishLabel: 'connectToExpert', + value: 218, + }, + ]; + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + async topTenQuestions() { + try { + const sessions = await this.sessionModel + .find() + .sort({ createdISO: -1 }) + .limit(10) + .exec(); + + const data = sessions.map((session, index) => ({ + rank: index + 1, + chatTitle: session.chatTitle, + createdISO: session.createdISO, + })); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (error) { + console.error('Error in topTenQuestions:', error); + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Error fetching top ten questions', + null, + ); + } + } + + async topTenUsers() { + try { + const topUsers = await this.sessionModel.aggregate([ + { + $group: { + _id: '$userId', + count: { $sum: 1 }, + }, + }, + { + $sort: { count: -1 }, + }, + { + $limit: 10, + }, + { + $lookup: { + from: 'user', + localField: '_id', + foreignField: '_id', + as: 'userData', + }, + }, + { + $unwind: '$userData', + }, + { + $project: { + userId: '$_id', + mobile: '$userData.mobile', + count: 1, + _id: 0, + }, + }, + ]); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', topUsers); + } catch (error) { + console.error('Error in topTenUsers:', error); + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Error fetching top ten users', + null, + ); + } + } + + async botAnswerReact( + dateRange: string, + status?: 'all' | 'bot' | 'online', + ) { + try { + // Split the date range + const [startDate, endDate] = dateRange.split('-'); + // Get date segments + const dateSegments = TimeHelper.divideDateRange(startDate, endDate); + + // Initialize data structure + const data = { + labels: dateSegments.map((segment) => ({ + start: segment.start, + end: segment.end, + })), + data: await Promise.all( + dateSegments.map(async (segment) => { + // Convert dates to ISO for MongoDB query + const startISO = TimeHelper.jalaliToISO(segment.start); + const endISO = TimeHelper.jalaliToISO(segment.end); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + // Build query for sessions created in date range + // Note: We count bot messages from ALL sessions, not just bot-only sessions, + // because sessions that connect to expert may still have bot messages that were rated + const query: any = { + createdISO: { + $gte: startDateObj, + $lte: endDateObj, + }, + }; + + // Apply status filter if provided + if (status === 'bot') { + query.connectedToExpert = false; + } else if (status === 'online') { + query.connectedToExpert = true; + } + // If status is 'all' or undefined, don't filter by connectedToExpert + + // Query sessions with messages field, use lean for performance + const sessions = await this.sessionModel + .find(query) + .select('messages') + .lean() + .exec(); + + // Count reactions + let positiveQuestions = 0; + let negativeQuestions = 0; + let noneAction = 0; + let totalBotMessages = 0; + + sessions.forEach((session) => { + session.messages?.forEach((message) => { + // Only process Bot messages + if (message.sender === 'Bot') { + totalBotMessages++; + // Normalize react value: trim whitespace and convert to lowercase + const react = message.react ? String(message.react).trim().toLowerCase() : null; + if (react === 'like') { + positiveQuestions++; + } else if (react === 'dislike') { + negativeQuestions++; + } else if (react === null || react === 'nothing') { + noneAction++; + } + } + }); + }); + + return { + start: segment.start, + end: segment.end, + positiveQuestions, + negativeQuestions, + noneAction, + totalBotMessages, + }; + }), + ), + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.error('Error in botAnswerReact:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalBotAnswersReact(dateRange?: string) { + try { + // Build query for sessions created in date range + // Note: We count bot messages from ALL sessions, not just bot-only sessions, + // because sessions that connect to expert may still have bot messages that were rated + const query: any = {}; + + // Parse date range if provided + if (dateRange) { + const [start, end] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(start); + const endISO = TimeHelper.jalaliToISO(end); + const startDate = new Date(startISO); + const endDate = new Date(endISO); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + // Filter sessions by createdISO (session creation date) + query.createdISO = { + $gte: startDate, + $lte: endDate, + }; + } + + // Query sessions with messages field, use lean for performance + const sessions = await this.sessionModel + .find(query) + .select('messages') + .lean() + .exec(); + + let totalBotMessages = 0; + let totalLikes = 0; + let totalDislikes = 0; + let totalRated = 0; // Count of bot messages that have been rated (like or dislike) + + // Count all bot messages from sessions created in date range + sessions.forEach((session) => { + session.messages?.forEach((message) => { + if (message.sender === 'Bot') { + totalBotMessages++; // Count all bot messages + + // Normalize react value: trim whitespace and convert to lowercase + // This ensures we correctly identify 'Like', 'like', 'Dislike', 'dislike', etc. + const react = message.react ? String(message.react).trim().toLowerCase() : null; + if (react === 'like') { + totalLikes++; + totalRated++; // Count as rated + } else if (react === 'dislike') { + totalDislikes++; + totalRated++; // Count as rated + } + } + }); + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + like: totalLikes, + dislike: totalDislikes, + rated: totalRated, // Total bot messages that were rated (like + dislike) + total: totalBotMessages, // Total bot messages (rated or not) + }); + } catch (err) { + console.error('Error in totalBotAnswersReact:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalConnectToExpertRate() { + try { + const sessions = await this.sessionModel + .find({}) + .select('connectedToExpert') + .lean() + .exec(); + + const totalSessions = sessions.length; + const connectedToExpert = sessions.filter( + (session) => session.connectedToExpert === true, + ).length; + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + totalSessions, + connectedToExpert, + }); + } catch (err) { + console.error('Error in totalConnectToExpertRate:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async activeUsers() { + try { + const totalActiveUsers = await this.userModel.countDocuments({}); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + totalActiveUsers, + }); + } catch (err) { + console.error('Error in activeUsers:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalSessions(dateRange?: string) { + try { + const query: any = {}; + + // Add date filter if provided + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // Count all sessions matching the query + const totalSessions = await this.sessionModel.countDocuments(query); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + totalSessions, + dateRange: dateRange || 'all', + }); + } catch (err) { + console.error('Error in totalSessions:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async expertMetrics() { + try { + const sessions = await this.sessionModel.find({}); + + let totalConnectedToExpert = 0; + let expertResponded = 0; + let totalResponseTime = 0; + let sessionsWithResponseTime = 0; + let totalWaitingTime = 0; + let sessionsWithExpertMessage = 0; + + sessions.forEach((session) => { + if (session.connectedToExpert) { + totalConnectedToExpert++; + + // Calculate expertResponseRate and avgResponseTime + if (session.onlineStartDate) { + expertResponded++; + if (session.onlineEndDate) { + const duration = Math.abs( + session.onlineEndDate.getTime() - + session.onlineStartDate.getTime(), + ); + totalResponseTime += duration; + sessionsWithResponseTime++; + } + } + } + + // Calculate avgUserWaitTime (similar to avgWaitingTime function) + if (session.onlineStartDate) { + const onlineStartDate = new Date(session.onlineStartDate); + + const firstExpertMessage = session.messages.find( + (message) => message.sender?.toLowerCase() === 'expert', + ); + + if (firstExpertMessage) { + const expertMessageDate = new Date(firstExpertMessage.createdISO); + const waitingTimeInSeconds = (expertMessageDate.getTime() - onlineStartDate.getTime()) / 1000; + totalWaitingTime += waitingTimeInSeconds; + sessionsWithExpertMessage++; + } + } + }); + + const expertResponseRate = totalConnectedToExpert > 0 ? (expertResponded / totalConnectedToExpert) * 100 : 0; + + const avgResponseTime = sessionsWithResponseTime > 0 ? (totalResponseTime / sessionsWithResponseTime / 60000) : 0; // in minutes + + const avgUserWaitTime = sessionsWithExpertMessage > 0 ? (totalWaitingTime / sessionsWithExpertMessage / 60) : 0; // in minutes + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + expertResponseRate: parseFloat(expertResponseRate.toFixed(2)), + avgResponseTime: parseFloat(avgResponseTime.toFixed(2)), + avgUserWaitTime: parseFloat(avgUserWaitTime.toFixed(2)), + }); + } catch (err) { + console.error('Error in expertMetrics:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async exportExpertReportsToExcel( + dateRange?: string, + experts?: string[], + rate?: string, + search?: string, + ): Promise { + try { + // Fetch data from the 4 endpoints that frontend uses: + // 1. Total Expert Answering + const totalAnsweringData = await this.totalAnswering(dateRange, experts); + + // 2. Average Expert Answering (response time) + const avgAnsweringData = await this.avgAnswering(dateRange, experts); + + // 3. Average Waiting Time + const avgWaitingTimeData = await this.avgWaitingTime(dateRange, experts); + + // 4. Expert List with filters + // Note: Expert list doesn't use date/activityType filters like other reports + // Frontend only uses rate and search filters for the expert list + const adminIdentity = { userData: { role: 'admin' } }; // Mock admin identity for service call + const expertListData = await this.userManagementService.findExpertsByFilter( + adminIdentity, + { + rate, + search, + // Don't pass date/activityType to expert list - it has different filter requirements + }, + ); + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + // Extract data from the 4 endpoints + const totalData = totalAnsweringData.data as any[]; + const avgData = avgAnsweringData.data as any[]; + const waitingData = avgWaitingTimeData.data as any[]; + const expertsList = expertListData.data as any[]; + + // Calculate summary metrics matching frontend display + // میانگین پاسخگویی کارشناسان (Average Expert Response Time) + // Frontend calculates: sum of all averageResponseTime / number of experts + // Frontend code: reduce((a, c) => a + c.avgTime, 0) / (avgAnswering.length || 1) + // Just do simple math - sum all values and divide by count (ignore units) + let avgExpertResponseTimeSeconds = 0; + if (avgData.length > 0) { + // Sum all averageResponseTime values + const sumOfAverages = avgData.reduce((sum, item) => sum + item.averageResponseTime, 0); + // Divide by number of experts (simple average) + avgExpertResponseTimeSeconds = sumOfAverages / avgData.length; + } + + // میانگین زمان انتظار کاربران (Average User Waiting Time) + // From avgWaitingTime endpoint - already in SECONDS + const avgUserWaitingTimeSeconds = waitingData.length > 0 + ? waitingData.reduce((sum, item) => sum + item.avgWaitingTime, 0) / waitingData.length + : 0; + + // تعداد کل پاسخ‌ها (Total Responses) + // From totalAnswering endpoint + const totalResponses = totalData.reduce((sum, item) => sum + item.answered, 0); + const totalNotAnswered = totalData.reduce((sum, item) => sum + item.notAnswered, 0); + const totalSessions = totalResponses + totalNotAnswered; + + // Calculate response rate percentage (answered / total * 100) + const responseRatePercentage = totalSessions > 0 + ? ((totalResponses / totalSessions) * 100).toFixed(1) + : '0.0'; + + // === Sheet 1: Key Metrics Summary === + const summarySheet = workbook.addWorksheet('📊 Key Metrics'); + + // Title + summarySheet.mergeCells('A1:D1'); + summarySheet.getCell('A1').value = 'Expert Performance Dashboard'; + summarySheet.getCell('A1').font = { size: 20, bold: true, color: { argb: 'FFFFFFFF' } }; + summarySheet.getCell('A1').fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF203864' }, + }; + summarySheet.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle' }; + summarySheet.getRow(1).height = 40; + + // Filter info + let currentRow = 3; + if (dateRange) { + summarySheet.mergeCells(`A${currentRow}:D${currentRow}`); + summarySheet.getCell(`A${currentRow}`).value = `📅 Date Range: ${dateRange}`; + summarySheet.getCell(`A${currentRow}`).font = { size: 12, bold: true }; + summarySheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + if (experts && experts.length > 0) { + summarySheet.mergeCells(`A${currentRow}:D${currentRow}`); + summarySheet.getCell(`A${currentRow}`).value = `👥 Filtered Experts: ${experts.join(', ')}`; + summarySheet.getCell(`A${currentRow}`).font = { size: 11, italic: true }; + summarySheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + + currentRow += 1; + + // Key Metrics Cards + const metricsStartRow = currentRow; + + // Row 1: Metric Labels + summarySheet.getCell(`A${metricsStartRow}`).value = 'میانگین پاسخگویی کارشناسان'; + summarySheet.getCell(`B${metricsStartRow}`).value = 'میانگین زمان انتظار کاربران'; + summarySheet.getCell(`C${metricsStartRow}`).value = 'تعداد کل پاسخ‌ها'; + summarySheet.getCell(`D${metricsStartRow}`).value = 'نرخ پاسخگویی'; + + summarySheet.getRow(metricsStartRow).font = { bold: true, size: 11 }; + summarySheet.getRow(metricsStartRow).alignment = { horizontal: 'center', vertical: 'middle' }; + summarySheet.getRow(metricsStartRow).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE7E6E6' }, + }; + + // Row 2: Metric Values (matching frontend exactly) + const valuesRow = metricsStartRow + 1; + summarySheet.getCell(`A${valuesRow}`).value = `${avgExpertResponseTimeSeconds.toFixed(1)} ثانیه`; + summarySheet.getCell(`B${valuesRow}`).value = `${avgUserWaitingTimeSeconds.toFixed(1)} ثانیه`; + summarySheet.getCell(`C${valuesRow}`).value = totalResponses; + summarySheet.getCell(`D${valuesRow}`).value = `${responseRatePercentage}%`; + + summarySheet.getRow(valuesRow).font = { bold: true, size: 16 }; + summarySheet.getRow(valuesRow).alignment = { horizontal: 'center', vertical: 'middle' }; + summarySheet.getRow(valuesRow).height = 30; + + // Color code the values + summarySheet.getCell(`A${valuesRow}`).font = { ...summarySheet.getCell(`A${valuesRow}`).font, color: { argb: 'FF4472C4' } }; + summarySheet.getCell(`B${valuesRow}`).font = { ...summarySheet.getCell(`B${valuesRow}`).font, color: { argb: 'FF70AD47' } }; + summarySheet.getCell(`C${valuesRow}`).font = { ...summarySheet.getCell(`C${valuesRow}`).font, color: { argb: 'FFFFC000' } }; + summarySheet.getCell(`D${valuesRow}`).font = { ...summarySheet.getCell(`D${valuesRow}`).font, color: { argb: 'FFE74C3C' } }; + + // Additional Summary Stats + currentRow = valuesRow + 3; + summarySheet.getCell(`A${currentRow}`).value = 'Summary Statistics'; + summarySheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + currentRow++; + + const statsData = [ + ['Total Experts', expertsList.length], + ['Total Sessions', totalSessions], + ['Answered Sessions', totalResponses], + ['Not Answered Sessions', totalNotAnswered], + ['Response Rate', `${responseRatePercentage}%`], + ['Average Expert Rating', expertsList.length > 0 ? (expertsList.reduce((sum, e) => sum + (e.avgRate || 0), 0) / expertsList.length).toFixed(2) : 'N/A'], + ]; + + statsData.forEach(([label, value]) => { + summarySheet.getCell(`A${currentRow}`).value = label; + summarySheet.getCell(`B${currentRow}`).value = value; + summarySheet.getCell(`A${currentRow}`).font = { bold: true }; + // Center align + summarySheet.getRow(currentRow).alignment = { horizontal: 'center', vertical: 'middle' }; + currentRow++; + }); + + summarySheet.columns.forEach((column) => { + column.width = 30; + }); + + // === Sheet 2: Expert List with Details === + const expertSheet = workbook.addWorksheet('👥 Experts List'); + + expertSheet.mergeCells('A1:L1'); + expertSheet.getCell('A1').value = 'Expert List with Performance Metrics'; + expertSheet.getCell('A1').font = { size: 16, bold: true }; + expertSheet.getCell('A1').alignment = { horizontal: 'center' }; + + if (dateRange) { + expertSheet.mergeCells('A2:L2'); + expertSheet.getCell('A2').value = `Date Range: ${dateRange}`; + expertSheet.getCell('A2').font = { size: 12 }; + expertSheet.getCell('A2').alignment = { horizontal: 'center' }; + } + + const expertHeaderRow = dateRange ? 4 : 3; + const expertHeaders = ['Mobile', 'Name', 'Family', 'Username', 'Total Sessions', 'Avg Rate', 'Admin Rate', 'Likes', 'Dislikes', 'First Session', 'Last Session', 'Status']; + const expertHeaderRowObj = expertSheet.getRow(expertHeaderRow); + expertHeaderRowObj.values = expertHeaders; + expertHeaderRowObj.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + expertHeaderRowObj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + expertHeaderRowObj.alignment = { horizontal: 'center', vertical: 'middle' }; + + expertsList.forEach((expert, index) => { + const row = expertSheet.getRow(expertHeaderRow + 1 + index); + + // Format dates properly + const formatDate = (dateValue: any) => { + if (!dateValue) return 'N/A'; + try { + const date = new Date(dateValue); + if (isNaN(date.getTime())) return 'N/A'; + // Format as YYYY-MM-DD HH:mm:ss + return date.toISOString().replace('T', ' ').substring(0, 19); + } catch { + return 'N/A'; + } + }; + + row.values = [ + expert.mobile, + expert.name, + expert.family, + expert.username, + expert.totalSessions, + expert.avgRate, + expert.adminRate || 'N/A', + expert.like || 0, + expert.dislike || 0, + formatDate(expert.firstSession), + formatDate(expert.lastSession), + expert.isActive ? 'Active' : 'Inactive', + ]; + + // Center align all cells in this row + row.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Color code status + const statusCell = row.getCell(12); + if (expert.isActive) { + statusCell.font = { color: { argb: 'FF70AD47' }, bold: true }; + } else { + statusCell.font = { color: { argb: 'FFE74C3C' }, bold: true }; + } + }); + + expertSheet.columns.forEach((column) => { + column.width = 18; + }); + + // === Sheet 3: Total Expert Answering === + const sheet1 = workbook.addWorksheet('Total Expert Answering'); + + // Add title + sheet1.mergeCells('A1:G1'); + sheet1.getCell('A1').value = 'Total Expert Answering Report'; + sheet1.getCell('A1').font = { size: 16, bold: true }; + sheet1.getCell('A1').alignment = { horizontal: 'center' }; + + // Add date range if provided + if (dateRange) { + sheet1.mergeCells('A2:G2'); + sheet1.getCell('A2').value = `Date Range: ${dateRange}`; + sheet1.getCell('A2').font = { size: 12 }; + sheet1.getCell('A2').alignment = { horizontal: 'center' }; + } + + // Add headers + const headerRow = dateRange ? 4 : 3; + const headers1 = ['Expert', 'First Name', 'Last Name', 'Answered', 'Not Answered', 'Total', 'Fastest (seconds)']; + const headerRow1 = sheet1.getRow(headerRow); + headerRow1.values = headers1; + headerRow1.font = { bold: true }; + headerRow1.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + headerRow1.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Add data + totalData.forEach((item, index) => { + const row = sheet1.getRow(headerRow + 1 + index); + row.values = [ + item.expert, + item.firstName, + item.lastName, + item.answered, + item.notAnswered, + item.total, + item.fastest, + ]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet1.columns.forEach((column) => { + column.width = 20; + }); + + // Add chart for Total Answering + const chartStartRow = headerRow + totalData.length + 3; + sheet1.getCell(`A${chartStartRow}`).value = 'Summary Chart:'; + sheet1.getCell(`A${chartStartRow}`).font = { bold: true, size: 12 }; + + // Add a simple bar chart representation (text-based since ExcelJS charts are complex) + const chartDataRow = chartStartRow + 1; + sheet1.getCell(`A${chartDataRow}`).value = 'Expert'; + sheet1.getCell(`B${chartDataRow}`).value = 'Answered'; + sheet1.getCell(`C${chartDataRow}`).value = 'Not Answered'; + sheet1.getRow(chartDataRow).font = { bold: true }; + sheet1.getRow(chartDataRow).alignment = { horizontal: 'center', vertical: 'middle' }; + + totalData.forEach((item, index) => { + const row = sheet1.getRow(chartDataRow + 1 + index); + row.values = [item.expert, item.answered, item.notAnswered]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // === Sheet 2: Average Expert Answering === + const sheet2 = workbook.addWorksheet('Average Expert Answering'); + + // Add title + sheet2.mergeCells('A1:E1'); + sheet2.getCell('A1').value = 'Average Expert Response Time Report'; + sheet2.getCell('A1').font = { size: 16, bold: true }; + sheet2.getCell('A1').alignment = { horizontal: 'center' }; + + // Add date range if provided + if (dateRange) { + sheet2.mergeCells('A2:E2'); + sheet2.getCell('A2').value = `Date Range: ${dateRange}`; + sheet2.getCell('A2').font = { size: 12 }; + sheet2.getCell('A2').alignment = { horizontal: 'center' }; + } + + // Add headers + const headerRow2 = dateRange ? 4 : 3; + const headers2 = ['First Name', 'Last Name', 'Average Response Time', 'Unit', 'Total Sessions']; + const headerRow2Obj = sheet2.getRow(headerRow2); + headerRow2Obj.values = headers2; + headerRow2Obj.font = { bold: true }; + headerRow2Obj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF70AD47' }, + }; + headerRow2Obj.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Add data + avgData.forEach((item, index) => { + const row = sheet2.getRow(headerRow2 + 1 + index); + row.values = [ + item.firstName, + item.lastName, + item.averageResponseTime, + item.averageResponseTimeUnit, + item.totalSessions, + ]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet2.columns.forEach((column) => { + column.width = 20; + }); + + // === Sheet 3: Average Waiting Time === + const sheet3 = workbook.addWorksheet('Average Waiting Time'); + + // Add title + sheet3.mergeCells('A1:C1'); + sheet3.getCell('A1').value = 'Average Waiting Time Report'; + sheet3.getCell('A1').font = { size: 16, bold: true }; + sheet3.getCell('A1').alignment = { horizontal: 'center' }; + + // Add date range if provided + if (dateRange) { + sheet3.mergeCells('A2:C2'); + sheet3.getCell('A2').value = `Date Range: ${dateRange}`; + sheet3.getCell('A2').font = { size: 12 }; + sheet3.getCell('A2').alignment = { horizontal: 'center' }; + } + + // Add headers + const headerRow3 = dateRange ? 4 : 3; + const headers3 = ['First Name', 'Last Name', 'Average Waiting Time (seconds)']; + const headerRow3Obj = sheet3.getRow(headerRow3); + headerRow3Obj.values = headers3; + headerRow3Obj.font = { bold: true }; + headerRow3Obj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFC000' }, + }; + headerRow3Obj.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Add data + waitingData.forEach((item, index) => { + const row = sheet3.getRow(headerRow3 + 1 + index); + row.values = [item.firstName, item.lastName, item.avgWaitingTime]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet3.columns.forEach((column) => { + column.width = 30; + }); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } catch (err) { + console.error('Error in exportExpertReportsToExcel:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Failed to generate Excel file', + null, + ); + } + } +} diff --git a/src/scripts/redis-normalize.ts b/src/scripts/redis-normalize.ts new file mode 100644 index 0000000..3056746 --- /dev/null +++ b/src/scripts/redis-normalize.ts @@ -0,0 +1,178 @@ +import 'reflect-metadata'; +import mongoose, { Model, Schema, Types } from 'mongoose'; +import { AdminModel, AdminSchema } from '../database/model/admin.model'; +import { SessionModel, SessionSchema } from '../database/model/sessions.model'; +import { config } from 'dotenv'; +import Redis from 'ioredis'; + +config({ path: '.local.env' }); + +type RoomRedisData = { + roomId: string; + sessionId: string; + userId: string; + expertId: string | null; + createdAt: [string, string]; + messages: any[]; + isActive: boolean; +}; + +const ROOMS_HASH_KEY = 'rooms:active'; + +function isEmailLike(id: string | null | undefined): boolean { + return !!id && /@/.test(id); +} + +function redactUri(uri: string) { + return uri.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:****@'); +} + +async function connectMongo() { + const uri = process.env.DATABASE_URL + || `mongodb://${process.env.MONGO_INITDB_ROOT_USERNAME}:${process.env.MONGO_INITDB_ROOT_PASSWORD}` + + `@${process.env.MONGO_HOST}:${process.env.MONGO_PORT}/chat_bot?authMechanism=DEFAULT&authSource=admin`; + console.log('[redis-normalize] Connecting to Mongo:', redactUri(uri)); + await mongoose.connect(uri); +} + +async function main() { + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + console.log('[redis-normalize] Connecting to Redis:', redisUrl); + const redis = new Redis(redisUrl); + try { + const pong = await redis.ping(); + console.log('[redis-normalize] Redis PING:', pong); + } catch (e) { + console.error('[redis-normalize] Redis connection error:', e); + throw e; + } + + await connectMongo(); + + // Initialize mongoose models for standalone script + const Admin = (mongoose.models.admin || mongoose.model('admin', AdminSchema)) as Model; + const Session = (mongoose.models.sessions || mongoose.model('sessions', SessionSchema)) as Model; + + const summary = { + roomsTotal: 0, + fixedExpertIds: 0, + removedRoomsOrphans: 0, + removedSessionKeys: 0, + movedSessionSets: 0, + }; + + // scan all rooms + const roomsRaw = await redis.hgetall(ROOMS_HASH_KEY); + summary.roomsTotal = Object.keys(roomsRaw).length; + console.log('[redis-normalize] rooms:active count =', summary.roomsTotal); + + for (const [roomKey, raw] of Object.entries(roomsRaw)) { + let room: RoomRedisData | null = null; + try { + room = JSON.parse(raw) as RoomRedisData; + } catch { + // corrupt JSON → delete + await redis.hdel(ROOMS_HASH_KEY, roomKey); + summary.removedRoomsOrphans++; + continue; + } + + const sessionId = String(room.sessionId); + + // Validate session exists in Mongo + const sessExists = await Session.exists({ _id: sessionId }); + if (!sessExists) { + await redis.hdel(ROOMS_HASH_KEY, roomKey); + // cleanup session:* keys + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + + const expertId = room.expertId ? String(room.expertId) : ''; + + // If expertId missing or email-like, try to map to Admin _id + if (!expertId || isEmailLike(expertId)) { + const mappedAdmin = expertId + ? await Admin.findOne({ $or: [{ email: expertId }, { username: expertId }] }).select('_id').lean() + : null; + + if (!mappedAdmin) { + // Cannot map → treat as orphan assignment → remove room and cleanup + await redis.hdel(ROOMS_HASH_KEY, roomKey); + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + + // map to ObjectId + const newExpertId = (mappedAdmin as any)._id.toString(); + room.expertId = newExpertId as any; + await redis.hset(ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + summary.fixedExpertIds++; + + // ensure session is present in canonical expert set + const setKey = `expert:${newExpertId}:sessions`; + const added = await redis.sadd(setKey, sessionId); + if (added) summary.movedSessionSets++; + } else { + // ExpertId is an ObjectId-looking string; verify it matches an admin + const isOid = Types.ObjectId.isValid(expertId); + if (!isOid) { + // invalid expert id → remove room & keys + await redis.hdel(ROOMS_HASH_KEY, roomKey); + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + const admin = await Admin.findById(expertId).select('_id').lean(); + if (!admin) { + // invalid reference → remove room & aux keys + await redis.hdel(ROOMS_HASH_KEY, roomKey); + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + } + } + + // Final pass: remove expert sets pointing to non-existing sessions + const [_, expertSetKeys] = await redis.scan(0, 'MATCH', 'expert:*:sessions', 'COUNT', 10000); + for (const setKey of expertSetKeys as string[]) { + const members = await redis.smembers(setKey); + for (const sid of members) { + const exists = await redis.hexists(ROOMS_HASH_KEY, `room:${sid}`); + if (!exists) { + await redis.srem(setKey, sid); + summary.movedSessionSets++; + } + } + } + + console.log('[redis-normalize] Summary:', summary); + await redis.quit(); + await mongoose.disconnect(); +} + +main().catch((err) => { + console.error('[redis-normalize] Error:', err); + process.exit(1); +}); + + diff --git a/src/socket/support-management/chat.gateway.ts b/src/socket/support-management/chat.gateway.ts new file mode 100644 index 0000000..7b0a161 --- /dev/null +++ b/src/socket/support-management/chat.gateway.ts @@ -0,0 +1,1661 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayInit, + OnGatewayConnection, + OnGatewayDisconnect, + } from '@nestjs/websockets'; + + import { Server, Socket } from 'socket.io'; +import { + JoinRoomPayload, + LeaveRoomPayload, + ExpertOnlinePayload, + RequestExpertPayload, + SendMessagePayload, + GetChatHistoryPayload, + EndChatPayload, + NotificationPayload, + GetTransferCandidatesPayload, + TransferChatPayload, + createSocketResponse, // Import the new helper +} from './dto/eventPayloads.dto'; + +import { ChatService, TransferChatResult } from './chat.service'; +import { HttpStatus, Injectable, UsePipes, ValidationPipe, UseFilters, ForbiddenException, BadRequestException } from '@nestjs/common'; // Import HttpStatus +import { InjectModel } from '@nestjs/mongoose'; +import { UserModel } from 'src/database/model/user.model'; +import { Model, Types } from 'mongoose'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { RedisService } from "src/common/helpers/redis.service"; +import { WsAllExceptionsFilter } from 'src/common/filters/ws-all-exceptions.filter'; +import { BusinessHoursService } from 'src/business-hours/business-hours.service'; +import { StructuredLogger, LogCategory } from 'src/common/helpers/structured-logger.service'; +import { AuditLogService } from 'src/common/services/audit-log.service'; + +@Injectable() +@WebSocketGateway({ + cors: { + origin: '*', // ⚠️ Adjust in production (restrict to your domains) + }, +}) +@UsePipes(new ValidationPipe({ whitelist: true, transform: true })) +@UseFilters(new WsAllExceptionsFilter()) +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new StructuredLogger(ChatGateway.name); + constructor( + private readonly chatService: ChatService, + @InjectModel(UserModel.name) private readonly userModel: Model, + @InjectModel(AdminModel.name) private readonly adminModel: Model, + @InjectModel(SessionModel.name) private readonly sessionModel: Model, + private readonly redisService: RedisService, + private readonly businessHoursService: BusinessHoursService, + private readonly auditLogService: AuditLogService, + + ) {} + private client() { + return this.redisService.getClient(); + } + // ----- Gateway Lifecycle ----- + afterInit() { + console.log('✅ Chat Gateway Initialized'); + this.startBackgroundWatchers(); + this.logger.log(LogCategory.BACKGROUND, 'Chat inactivity/reassignment watchers started'); + const checkIntervalMs = process.env.CHECK_INTERVAL_MS ? parseInt(process.env.CHECK_INTERVAL_MS as any, 10) * 1000 : 60 * 1000; + const repairSeconds = process.env.REPAIR_REDIS_INTERVAL_SECONDS ? parseInt(process.env.REPAIR_REDIS_INTERVAL_SECONDS as any, 10) : 600; + this.logger.log(LogCategory.INFO, 'Gateway initialized', { checkIntervalMs, repairSeconds }); + this.startRepairScheduler(); + } + + async handleConnection(client: Socket) { + const clientId = client.id; + this.logger.log(LogCategory.CONNECTION, 'Client connected', { clientId }); + // Audit log for connection + this.auditLogService.logWebSocketEvent('clientConnected', client, { + metadata: { clientId }, + }).catch(() => {}); // Fire and forget + } + + async handleDisconnect(client: Socket) { + try { + await this.chatService.handleDisconnect(client); + this.logger.log(LogCategory.DISCONNECT, 'Client disconnected', { clientId: client.id }); + // Audit log for disconnection + this.auditLogService.logWebSocketEvent('clientDisconnected', client, { + metadata: { clientId: client.id }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'handleDisconnect error', err as any, { clientId: client.id }); + } + } + // Automatically triggered background timers + private startBackgroundWatchers() { + // run every 60 seconds + const checkIntervalMs = process.env.CHECK_INTERVAL_MS ? parseInt(process.env.CHECK_INTERVAL_MS, 10) * 1000 : 60 * 1000; + setInterval(async () => { + try { + // 1) Reassign inactive experts + const reassignments = await this.chatService.checkAndReassignInactiveExperts(); + if (Array.isArray(reassignments) && reassignments.length) { + for (const r of reassignments) { + // Note: When no expert is available during reassignment, we don't close the chat immediately. + // The session remains active and will be closed by the inactivity timer (checkAndCloseInactiveChats) + // which will emit chatAutoClosed event. + + const roomId = `room:${r.sessionId}`; + + // Fetch expert basic info for client response (only family, not name) + let expertInfo: { family: string } | null = null; + if (r.newExpertId && Types.ObjectId.isValid(r.newExpertId)) { + const admin = await this.adminModel.findById(r.newExpertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + + // notify room (user) and new expert if any + this.server.to(roomId).emit('response', createSocketResponse( + 'expertReassigned', + { sessionId: r.sessionId, oldExpertId: r.oldExpertId, newExpertId: r.newExpertId, ...(expertInfo ? { expert: expertInfo } : {}) }, + 'Expert reassigned due to inactivity', + HttpStatus.OK, + )); + if (r.newExpertId) { + // send a direct notification to new expert socket if available + // fetch expert raw to get socketId + const experts = await this.chatService.getOnlineExperts(); + const found = experts.find((e:any)=> e.expertId === r.newExpertId); + if (found?.socketId) { + this.server.to(found.socketId).emit('response', createSocketResponse( + 'newAutoAssignedSession', + { sessionId: r.sessionId, roomId }, + 'You have been auto-assigned a session', + HttpStatus.OK, + )); + } + } + } + } + + // 2) Auto-close inactive chats + const closed = await this.chatService.checkAndCloseInactiveChats(); + if (Array.isArray(closed) && closed.length) { + for (const { sessionId, userId } of closed) { + const roomId = `room:${sessionId}`; + const notification = createSocketResponse( + 'chatAutoClosed', + { sessionId, role: 'system' }, + 'Chat auto-closed due to inactivity', + HttpStatus.OK, + ); + + // Notify room (for users/experts in the room) + this.server.to(roomId).emit('response', notification); + + // Also send direct notification to user's socket if they're connected + // This ensures users receive the notification even if they're not in the room + if (userId) { + const userSocketId = this.chatService.getConnectedSocketId(userId); + if (userSocketId) { + this.server.to(userSocketId).emit('response', notification); + this.logger.log(LogCategory.CHAT_AUTO_CLOSE, 'Sent auto-close notification', { userId, socketId: userSocketId, sessionId }); + } + } + } + } + // 3) Auto-reassign sessions after expert disconnect grace period + const disconnectReassignments = + await this.chatService.checkAndReassignDisconnectedExperts(); + for (const r of disconnectReassignments) { + await this.notifyExpertReassigned(r.sessionId, r.oldExpertId, r.newExpertId, r.userId); + } + + // 4) Auto-offline inactive experts + // Get online experts with socket IDs before offlining them + const onlineExpertsBeforeOffline = await this.chatService.getOnlineExperts(); + const offlined = await this.chatService.checkAndOfflineInactiveExperts(); + if (Array.isArray(offlined) && offlined.length) { + this.logger.log(LogCategory.EXPERT_OFFLINE, 'Auto-offlined experts', { expertIds: offlined }); + // Send notification to each offlined expert's socket + for (const expertId of offlined) { + // Find the expert's socket ID from the list we got before offlining + const expert = onlineExpertsBeforeOffline.find((e: any) => e.expertId === expertId); + if (expert?.socketId) { + this.server.to(expert.socketId).emit('response', createSocketResponse( + 'expertAutoOfflined', + { expertId }, + 'You have been automatically set offline due to inactivity', + HttpStatus.OK, + )); + this.logger.log(LogCategory.EXPERT_OFFLINE, 'Sent auto-offline notification', { expertId, socketId: expert.socketId }); + } + } + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'Background watcher error', err as any); + } + }, checkIntervalMs); + + // Run junk room cleanup every hour + setInterval(async () => { + try { + await this.chatService.cleanupJunkRooms(); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'Junk room cleanup error', err as any); + } + }, 60 * 5 * 1000); +} + +// Periodic Redis consistency repair +private startRepairScheduler() { + const seconds = process.env.REPAIR_REDIS_INTERVAL_SECONDS + ? parseInt(process.env.REPAIR_REDIS_INTERVAL_SECONDS as any, 10) + : 600; // default 10 minutes + const intervalMs = Math.max(60, seconds) * 1000; + setInterval(async () => { + try { + this.logger.log(LogCategory.REDIS_REPAIR, 'Repair tick started', { timestamp: new Date().toISOString(), cleanStale: true }); + const repaired = await this.chatService.repairRedisConsistency({ cleanStale: true }); + if (Array.isArray(repaired) && repaired.length) { + this.logger.warn(LogCategory.REDIS_REPAIR, 'Redis consistency repaired', { fixedCount: repaired.length, expertIds: repaired }); + } else { + this.logger.log(LogCategory.REDIS_REPAIR, 'No repairs needed'); + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'repairRedisConsistency scheduler error', err as any); + } + }, intervalMs); +} + +// // When a message is sent +// async onMessageReceived(sessionId: string, userId: string, message: string) { +// await this.chatService.updateLastActivity(sessionId); +// this.server.to(sessionId).emit('newMessage', { userId, message }); +// } + +// // When expert opens chat +// async onExpertSeen(sessionId: string, expertId: string) { +// await this.chatService.markSeen(sessionId, expertId); +// this.server.to(sessionId).emit('expertSeen', { expertId }); +// } + + // ----- Join / Leave Rooms ----- + @SubscribeMessage('joinRoom') + async onJoinRoom( + @MessageBody() payload: JoinRoomPayload, + @ConnectedSocket() client: Socket, + ) { + try { + // Check business hours - only for Users (not Experts) + if (payload?.role === 'User') { + const status = await this.businessHoursService.isOpen(); + if (!status.open) { + const nextOpen = await this.businessHoursService.getNextOpen(); + const message = status.reason || 'Online conversation is not available now. Please try again later.'; + return client.emit('response', createSocketResponse( + 'ONLINE_CONVERSATION_DISABLED', + { + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }, + message, + HttpStatus.SERVICE_UNAVAILABLE, + )); + } + } + + // If the socket is already in this room, short-circuit with idempotent ack + const roomsSet = (client as any).rooms as Set; + if (payload?.roomId && roomsSet?.has(payload.roomId)) { + this.server + .to(client.id) + .emit('response', createSocketResponse( + 'alreadyJoined', + { success: true, roomId: payload.roomId, alreadyInRoom: true }, + 'Already in room', + HttpStatus.OK, + )); + this.logger.log(LogCategory.JOIN, 'User already in room', { userId: payload.userId, roomId: payload.roomId }); + return; + } + + // Check if session is closed (for Experts) - prevent joining closed sessions + if (payload?.role === 'Expert' && payload?.roomId) { + const sessionId = (payload.roomId || '').replace(/^room:/, ''); + if (sessionId && Types.ObjectId.isValid(sessionId)) { + try { + const session = await this.sessionModel.findById(sessionId).select('onlineChatClosed chatClosed').lean(); + if (session && (session.onlineChatClosed || session.chatClosed)) { + this.logger.warn(LogCategory.JOIN, 'Expert attempted to join closed session', { + expertId: payload.userId, + sessionId, + onlineChatClosed: session.onlineChatClosed, + chatClosed: session.chatClosed + }); + return client.emit('response', createSocketResponse( + 'sessionClosed', + { + sessionId, + roomId: payload.roomId, + reason: 'This chat session has been closed' + }, + 'Cannot join closed chat session', + HttpStatus.CONFLICT, + )); + } + } catch (err) { + this.logger.error(LogCategory.JOIN, 'Error checking session status on joinRoom', { sessionId }, err); + // Continue anyway - don't block join if check fails + } + } + } + + await this.chatService.registerClient(payload, client.id); + + // Join socket.io room + if (payload.roomId) client.join(payload.roomId); + + if (payload?.role === 'Expert' && payload?.roomId) { + // Mark OLD messages (sent before expert joined) as seen, but NOT new messages sent after join + const sessionId = (payload.roomId || '').replace(/^room:/, ''); + await this.chatService.cancelPendingExpertTimer(sessionId); + if (sessionId) { + // Capture timestamp when expert joins - only mark messages created BEFORE this time + const joinTimestamp = new Date(); + this.chatService.markMessagesSeenBeforeTimestamp(sessionId, 'expert', joinTimestamp).catch((err) => { + this.logger.warn(LogCategory.MESSAGE_SEEN, 'Auto markMessagesSeenBeforeTimestamp failed on joinRoom', { sessionId }, err); + }); + + // bump expert last active + if (payload.userId) { + await this.chatService.refreshExpertLastActive(payload.userId); + } + // ensure expert/session mapping exists so getExpertSessions reflects this session + if (payload.userId && Types.ObjectId.isValid(payload.userId)) { + await this.chatService.ensureExpertSessionMapping(payload.userId, sessionId); + } + } + } + + // If expert joins, fetch and include expert's family (case-insensitive role check) + let expertInfo: { family: string } | null = null; + if (payload?.role && typeof payload.role === 'string' && payload.role.toLowerCase() === 'expert') { + const expertId = payload.userId; + if (expertId && Types.ObjectId.isValid(expertId)) { + const admin = await this.adminModel.findById(expertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + } + + this.server + .to(client.id) + .emit('response', createSocketResponse( + 'joinedRoom', + { success: true, roomId: payload.roomId, ...(expertInfo ? { expert: expertInfo } : {}) }, + 'Successfully joined room', + HttpStatus.OK, + )); + + this.logger.log(LogCategory.JOIN, 'User joined room', { userId: payload.userId, roomId: payload.roomId, role: payload.role }); + + // Audit log for join room + this.auditLogService.logWebSocketEvent('joinRoom', client, { + userId: payload.userId, + resource: 'Room', + resourceId: payload.roomId?.replace(/^room:/, '') || payload.roomId, + statusCode: HttpStatus.OK, + metadata: { role: payload.role, success: true }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onJoinRoom error', err as any, { userId: payload?.userId, roomId: payload?.roomId }); + + // Audit log for failed join room + this.auditLogService.logWebSocketEvent('joinRoom', client, { + userId: payload?.userId, + resource: 'Room', + resourceId: payload?.roomId?.replace(/^room:/, '') || payload?.roomId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { role: payload?.role, error: (err as any)?.message, success: false }, + }).catch(() => {}); // Fire and forget + client.emit('response', createSocketResponse( + 'joinRoom', + {}, + 'Failed to join room', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('leave_room') + async handleLeaveRoom( + @ConnectedSocket() client: Socket, + @MessageBody() payload: LeaveRoomPayload, + ) { + await client.leave(payload.roomId); + this.chatService.unregisterClient(payload.userId); + const user = await this.userModel.findOne({_id:payload.userId}); + this.server.to(payload.roomId).emit( + 'response', + createSocketResponse( + 'leave_room', + payload, + `A ${payload.role} with mobile ${user.mobile} Left. Name: ${user.name} ${user.family}`, + HttpStatus.OK, + ), + ); + + // Audit log for leave room + this.auditLogService.logWebSocketEvent('leaveRoom', client, { + userId: payload.userId, + resource: 'Room', + resourceId: payload.roomId?.replace(/^room:/, '') || payload.roomId, + statusCode: HttpStatus.OK, + metadata: { role: payload.role }, + }).catch(() => {}); // Fire and forget + } + + // ----- Expert Online / Offline ----- + @SubscribeMessage('expertOnline') + async onExpertOnline( + @MessageBody() payload: ExpertOnlinePayload, + @ConnectedSocket() client: Socket, + ) { + try { + if (!payload?.expertId || !Types.ObjectId.isValid(payload.expertId) || /@/.test(String(payload.expertId))) { + return client.emit('response', createSocketResponse( + 'expertOnline', + {}, + 'Invalid expertId: must be a Mongo ObjectId', + HttpStatus.BAD_REQUEST, + )); + } + const assignments = await this.chatService.setExpertOnline(payload.expertId, client.id); + client.emit('response', createSocketResponse( + 'expertOnlineAck', + { success: true }, + 'Expert is online', + HttpStatus.OK, + )); + this.logger.log(LogCategory.EXPERT_ONLINE, 'Expert is online', { expertId: payload.expertId, socketId: client.id }); + + // Audit log for expert online + this.auditLogService.logWebSocketEvent('expertOnline', client, { + userId: payload.expertId, + resource: 'Expert', + resourceId: payload.expertId, + statusCode: HttpStatus.OK, + metadata: { socketId: client.id, assignmentsCount: assignments?.length || 0 }, + }).catch(() => {}); // Fire and forget + + // Touch last active + await this.chatService.refreshExpertLastActive(payload.expertId); + + // If any queued users were assigned because this expert came online, notify both sides now + if (Array.isArray(assignments) && assignments.length) { + for (const a of assignments) { + if (a.isTransfer) { + client.join(a.roomId); + await this.notifyExpertReassigned(a.sessionId, null, a.expertId, a.userId); + } else { + client.join(a.roomId); + client.emit('response', createSocketResponse( + 'newUserAssigned', + { userId: a.userId, roomId: a.roomId }, + 'New user assigned to expert', + HttpStatus.OK, + )); + + const userSocketId = this.chatService.getConnectedSocketId(a.userId); + if (userSocketId) { + this.server.sockets.sockets.get(userSocketId)?.join(a.roomId); + this.server.to(userSocketId).emit('response', createSocketResponse( + 'newUserAssigned', + { userId: a.userId, roomId: a.roomId, expertId: a.expertId }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + } else { + this.server.to(a.roomId).emit('response', createSocketResponse( + 'expertAssigned', + { expertId: a.expertId, roomId: a.roomId }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + } + } + + this.logger.log(LogCategory.USER_QUEUE, 'Queued user assigned to expert on expertOnline', { userId: a.userId, expertId: a.expertId, roomId: a.roomId }); + } + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onExpertOnline error', err as any, { expertId: payload?.expertId }); + client.emit('response', createSocketResponse( + 'expertOnline', + {}, + 'Failed to set expert online', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('expertOffline') + async onExpertOffline( + @MessageBody() payload: ExpertOnlinePayload, + @ConnectedSocket() client: Socket, + ) { + try { + if (!payload?.expertId || !Types.ObjectId.isValid(payload.expertId) || /@/.test(String(payload.expertId))) { + return client.emit('response', createSocketResponse( + 'expertOffline', + {}, + 'Invalid expertId: must be a Mongo ObjectId', + HttpStatus.BAD_REQUEST, + )); + } + await this.chatService.setExpertOffline(payload.expertId); + client.emit('response', createSocketResponse( + 'expertOfflineAck', + { success: true }, + 'Expert is offline', + HttpStatus.OK, + )); + this.logger.log(LogCategory.EXPERT_OFFLINE, 'Expert went offline', { expertId: payload.expertId }); + + // Audit log for expert offline + this.auditLogService.logWebSocketEvent('expertOffline', client, { + userId: payload.expertId, + resource: 'Expert', + resourceId: payload.expertId, + statusCode: HttpStatus.OK, + }).catch(() => {}); // Fire and forget + } catch (err) { + const status = + err instanceof ForbiddenException + ? HttpStatus.FORBIDDEN + : HttpStatus.INTERNAL_SERVER_ERROR; + const message = + err instanceof ForbiddenException + ? (err as ForbiddenException).message + : 'Failed to set expert offline'; + this.logger.error(LogCategory.ERROR, 'onExpertOffline error', err as any, { expertId: payload?.expertId }); + this.auditLogService.logWebSocketEvent('expertOffline', client, { + userId: payload?.expertId, + resource: 'Expert', + resourceId: payload?.expertId, + statusCode: status, + metadata: { error: (err as any)?.message }, + }).catch(() => {}); + client.emit('response', createSocketResponse('expertOffline', {}, message, status)); + } + } + + @SubscribeMessage('getTransferCandidates') + async onGetTransferCandidates( + @MessageBody() payload: GetTransferCandidatesPayload, + @ConnectedSocket() client: Socket, + ) { + try { + const candidates = await this.chatService.getTransferCandidates( + payload.sessionId, + payload.expertId, + ); + const canTransfer = candidates.length > 0; + client.emit( + 'response', + createSocketResponse( + 'transferCandidates', + { sessionId: payload.sessionId, canTransfer, candidates }, + canTransfer + ? 'Transfer candidates loaded' + : 'No experts available for transfer', + HttpStatus.OK, + ), + ); + } catch (err) { + const status = + err instanceof BadRequestException || err instanceof ForbiddenException + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; + client.emit( + 'response', + createSocketResponse( + 'getTransferCandidates', + {}, + (err as Error)?.message || 'Failed to load transfer candidates', + status, + ), + ); + } + } + + @SubscribeMessage('transferChat') + async onTransferChat( + @MessageBody() payload: TransferChatPayload, + @ConnectedSocket() client: Socket, + ) { + try { + const result = await this.chatService.transferChat({ + sessionId: payload.sessionId, + fromExpertId: payload.expertId, + mode: payload.mode, + targetExpertId: payload.targetExpertId, + reason: payload.reason, + }); + await this.handleTransferResult(result, client); + } catch (err) { + const status = + err instanceof BadRequestException || err instanceof ForbiddenException + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; + client.emit( + 'response', + createSocketResponse( + 'transferChat', + {}, + (err as Error)?.message || 'Transfer failed', + status, + ), + ); + } + } + + // ----- Matching ----- + @SubscribeMessage('requestExpert') + async onRequestExpert( + @MessageBody() payload: RequestExpertPayload, + @ConnectedSocket() client: Socket, + ) { + try { + // Check business hours first + const status = await this.businessHoursService.isOpen(); + if (!status.open) { + const nextOpen = await this.businessHoursService.getNextOpen(); + const message = status.reason || 'Online conversation is not available now. Please try again later.'; + return client.emit('response', createSocketResponse( + 'ONLINE_CONVERSATION_DISABLED', + { + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }, + message, + HttpStatus.SERVICE_UNAVAILABLE, + )); + } + + if (!payload?.userId) { + client.emit('response', createSocketResponse( + 'requestExpert', + {}, + 'Missing userId', + HttpStatus.BAD_REQUEST, + )); + return; + } + + // Check if user has any active sessions and force close them before creating a new one + // This handles the case where user has an old active session but starts a new one + const closedSessions = await this.chatService.forceCloseUserActiveSessions(String(payload.userId)); + if (closedSessions.length > 0) { + this.logger.log(LogCategory.CHAT_CLOSE, 'Force closed previous active sessions', { userId: payload.userId, closedCount: closedSessions.length, sessionIds: closedSessions }); + + // Notify user that previous session was closed + for (const closedSessionId of closedSessions) { + const roomId = `room:${closedSessionId}`; + // Notify room participants (if any are still connected) + this.server.to(roomId).emit('response', createSocketResponse( + 'chatEnded', + { sessionId: closedSessionId, reason: 'Previous session closed due to new expert request', role: 'user' }, + 'Previous chat session ended', + HttpStatus.OK, + )); + } + } + + console.log(payload) + // Pass business hours status to assignExpert + const assignment = await this.chatService.assignExpert(payload, status.open); + console.log(assignment) + + // Handle different assignment results + if (assignment.status === 'queued') { + const position = await this.chatService.getWaitingPosition( + payload.userId, + ); + client.emit('response', createSocketResponse( + 'queued', + { position }, + 'All experts are busy. You are in queue.', + HttpStatus.SERVICE_UNAVAILABLE, + )); + + // Audit log for queued request + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload.userId, + resource: 'Chat', + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + metadata: { queued: true, position }, + }).catch(() => {}); // Fire and forget + + return; + } + + // Handle case when no experts are available during business hours + if (assignment.status === 'no_experts_available') { + client.emit('response', createSocketResponse( + 'NO_EXPERTS_AVAILABLE', + { + code: 'NO_EXPERTS_AVAILABLE', + message: 'No experts are currently available. Please try again later.', + }, + 'No experts are currently available. Please try again later.', + HttpStatus.SERVICE_UNAVAILABLE, + )); + + // Audit log for no experts available + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload.userId, + resource: 'Chat', + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + metadata: { noExpertsAvailable: true }, + }).catch(() => {}); // Fire and forget + + return; + } + + // Successful assignment + const { roomId, expertId, socketId } = assignment; + + // Notify both sides + client.join(roomId); + this.server + .to(socketId) + .emit('response', createSocketResponse( + 'newUserAssigned', + { userId: payload.userId, roomId }, + 'New user assigned to expert', + HttpStatus.OK, + )); + + // Fetch expert basic info for client response + let expertInfo: { family: string } | null = null; + if (expertId && Types.ObjectId.isValid(expertId)) { + const admin = await this.adminModel.findById(expertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + + client.emit('response', createSocketResponse( + roomId, + { roomId, expertId, ...(expertInfo ? { expert: expertInfo } : {}) }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + + this.logger.log(LogCategory.CHAT_START, 'User connected to expert', { userId: payload.userId, expertId, roomId }); + + // Audit log for request expert (successful assignment) + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload.userId, + resource: 'Chat', + resourceId: roomId?.replace(/^room:/, '') || roomId, + newValues: { expertId, roomId, assigned: true }, + statusCode: HttpStatus.OK, + metadata: { expertId, roomId }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onRequestExpert error', err as any, { userId: payload?.userId }); + + // Audit log for failed request expert + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload?.userId, + resource: 'Chat', + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { error: (err as any)?.message }, + }).catch(() => {}); // Fire and forget + client.emit('response', createSocketResponse( + 'requestExpert', + {}, + 'Failed to request expert', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + // ----- Messaging ----- + @SubscribeMessage('sendMessage') +async onSendMessage( + @MessageBody() payload: SendMessagePayload, + @ConnectedSocket() client: Socket, +) { + try { + // Normalize/require sessionId + const canonicalSessionId = payload.sessionId || ( + payload.roomId + ? (payload.roomId.startsWith('room:') ? payload.roomId.replace(/^room:/, '') : payload.roomId) + : '' + ); + if (!canonicalSessionId) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'Missing sessionId and unparseable roomId', { payloadRoomId: payload.roomId }); + return client.emit('response', createSocketResponse('sendMessage', {}, 'Missing sessionId', HttpStatus.BAD_REQUEST)); + } + + const roomId = `room:${canonicalSessionId}`; + if (payload.roomId && payload.roomId !== roomId) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'RoomId mismatch, using canonical', { payloadRoomId: payload.roomId, canonicalRoomId: roomId }); + } + + const normalizedPayload: SendMessagePayload = { + ...payload, + sessionId: canonicalSessionId, + roomId, + } as any; + + // Save the message via service (this now also persists to Mongo) + const savedMessage = await this.chatService.saveMessage(normalizedPayload); + const isExpertMessage = String(payload.senderRole).toLowerCase() === 'expert'; + await this.chatService.refreshInactiveChatTimer(canonicalSessionId, isExpertMessage); + // If expert sent the message, bump expert last active + if (isExpertMessage && payload.senderId) { + await this.chatService.refreshExpertLastActive(String(payload.senderId)); + } + if (payload.roomId && payload.roomId !== roomId) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'RoomId mismatch, using canonical', { payloadRoomId: payload.roomId, canonicalRoomId: roomId }); + } + + // 1) Acknowledge the sender with messageAck (contains savedMessage) + client.emit('messageAck', { + status: 'ok', + event: 'messageAck', + data: { ...savedMessage, roomId, sessionId: canonicalSessionId }, + }); + + // 2) Broadcast newMessage to all other members in the room (EXCLUDE sender) + client.to(roomId).emit( + 'response', + createSocketResponse('newMessage', { + roomId, + sessionId: canonicalSessionId, + senderId: payload.senderId, + senderRole: payload.senderRole, + message: { ...savedMessage }, + }), +); + +const members = this.server.sockets.adapter.rooms.get(roomId) || new Set(); +this.logger.debug(LogCategory.ROOM, 'Room members', { roomId, memberCount: members.size, members: [...members] }); + const isExpertViewer = payload.senderRole === 'User'; + const isUserViewer = payload.senderRole === 'Expert'; + if (isExpertViewer) { + // ❌ REMOVED: Auto-mark when user sends message doesn't make sense + // Expert should explicitly mark messages as seen or send a message to indicate they've seen them + // When expert sends a message, previous messages are marked as seen (see isUserViewer block below) + + // Fallback delivery: if expert socket is not in the room, emit directly to expert socket + try { + const roomDetails: any = await this.chatService.getRoomDetails(roomId); + const expertId = String(roomDetails?.expert?._id || roomDetails?.expertId || ''); + if (expertId) { + const experts = await this.chatService.getOnlineExperts(); + const target = experts.find((e: any) => e.expertId === expertId); + if (target?.socketId && !members.has(target.socketId)) { + this.server.to(target.socketId).emit('response', createSocketResponse( + 'newMessage', + { + roomId, + sessionId: canonicalSessionId, + senderId: payload.senderId, + senderRole: payload.senderRole, + message: { ...savedMessage }, + }, + 'New message', + HttpStatus.OK, + )); + } + } + } catch (err) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'Fallback emit to expert failed', { roomId }, err as any); + } +} +if (isUserViewer) { + // ✅ When expert sends a message, mark previous messages as seen by expert + // This indicates expert is actively engaging and has seen previous messages + await this.chatService.markMessagesSeen(canonicalSessionId, 'expert'); + + // Fallback delivery: if user socket is not in the room, emit directly to user socket when known + try { + const roomDetails: any = await this.chatService.getRoomDetails(roomId); + const userId = String(roomDetails?.userId || ''); + if (userId) { + const userSocketId = this.chatService.getConnectedSocketId(userId); + if (userSocketId && !members.has(userSocketId)) { + this.server.to(userSocketId).emit('response', createSocketResponse( + 'newMessage', + { + roomId, + sessionId: canonicalSessionId, + senderId: payload.senderId, + senderRole: payload.senderRole, + message: { ...savedMessage }, + }, + 'New message', + HttpStatus.OK, + )); + } + } + } catch (err) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'Fallback emit to user failed', { roomId }, err as any); + } +} + + this.logger.log(LogCategory.MESSAGE_SEND, 'Message sent', { senderId: payload.senderId, senderRole: payload.senderRole, roomId, sessionId: canonicalSessionId, messageLength: payload.message?.length }); + + // Audit log for send message + this.auditLogService.logWebSocketEvent('sendMessage', client, { + userId: payload.senderId, + resource: 'Message', + resourceId: savedMessage?.messageId?.toString() || canonicalSessionId, + newValues: { + sessionId: canonicalSessionId, + roomId, + senderRole: payload.senderRole, + messageLength: payload.message?.length, + }, + statusCode: HttpStatus.OK, + metadata: { + sessionId: canonicalSessionId, + roomId, + senderRole: payload.senderRole, + messageId: savedMessage?.messageId?.toString(), + }, + }).catch(() => {}); // Fire and forget + } catch (err) { + const canonicalSessionId = payload.sessionId || (payload.roomId ? (payload.roomId.startsWith('room:') ? payload.roomId.replace(/^room:/, '') : payload.roomId) : ''); + this.logger.error(LogCategory.ERROR, 'onSendMessage error', err as any, { sessionId: canonicalSessionId, senderId: payload?.senderId }); + + // Audit log for failed send message + this.auditLogService.logWebSocketEvent('sendMessage', client, { + userId: payload?.senderId, + resource: 'Message', + resourceId: canonicalSessionId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { error: (err as any)?.message, sessionId: canonicalSessionId }, + }).catch(() => {}); // Fire and forget + const message = (err as any)?.message || 'Failed to send message'; + const isReplyError = + typeof message === 'string' && + (message.includes('Reply target') || message.includes('Invalid replyToMessageId')); + const statusCode = message.includes('closed') + ? HttpStatus.CONFLICT + : isReplyError + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; + client.emit('response', createSocketResponse( + 'sendMessage', + {}, + message, + statusCode, + )); + } +} + + // ----- Chat History ----- + @SubscribeMessage('getChatHistory') + async onGetChatHistory( + @MessageBody() payload: { sessionId: string }, + @ConnectedSocket() client: Socket, + ) { + try { + const result = await this.chatService.getChatHistory(payload.sessionId); + // console.log(result) + client.emit('response', createSocketResponse( + 'chatHistory', + { + sessionId: payload.sessionId, + history: result.history, + createdAt: result.createdAt, + onlineStartDate: result.onlineStartDate, + }, + 'Chat history retrieved successfully', + HttpStatus.OK, + )); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetChatHistory error', err as any, { sessionId: payload?.sessionId }); + client.emit('response', createSocketResponse( + 'getChatHistory', + {}, + 'Failed to get chat history', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('getWaitingUsers') +async onGetWaitingUsers(@ConnectedSocket() client: Socket) { + try { + const waiting = await this.chatService.getWaitingUsers(); + + client.emit('response', createSocketResponse( + 'waitingUsers', + { + waiting, + count:waiting.length + }, + `Sent ${waiting.length} waiting users to client`, + HttpStatus.OK, + + )); + this.logger.log(LogCategory.USER_QUEUE, 'Sent waiting users to client', { count: waiting.length }); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'getWaitingUsers error', err as any); + client.emit('response', createSocketResponse( + 'waitingUsers', + {}, + 'Failed to fetch waiting users', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } +} + + // ----- End Chat ----- + @SubscribeMessage('endChat') + async onEndChat( + @MessageBody() payload: { sessionId: string; userId: string }, + @ConnectedSocket() client: Socket, + ) { + try { + // Check if session is already closed before calling endChat + const session = await this.sessionModel.findById(payload.sessionId).select('onlineChatClosed chatClosed expert userId').lean(); + const wasAlreadyClosed = session && (session.onlineChatClosed || session.chatClosed); + + // Determine who closed the chat: expert, user, or system + let closedByRole: 'expert' | 'user' | 'system' = 'user'; // default to user + + if (session) { + // Get room details to find expertId + try { + const roomId = `room:${payload.sessionId}`; + const roomDetails: any = await this.chatService.getRoomDetails(roomId); + const expertId = roomDetails?.expertId || roomDetails?.expert?._id; + + // Check if payload.userId matches the expert + if (expertId && String(expertId) === String(payload.userId)) { + closedByRole = 'expert'; + } else if (session.userId && String(session.userId) === String(payload.userId)) { + closedByRole = 'user'; + } else { + // If userId doesn't match either, check if it's an expert by querying AdminModel + if (Types.ObjectId.isValid(payload.userId)) { + const admin = await this.adminModel.findById(payload.userId).lean(); + if (admin) { + closedByRole = 'expert'; + } + } + } + } catch (err) { + // If room doesn't exist or error, try to determine from session + if (session.userId && String(session.userId) === String(payload.userId)) { + closedByRole = 'user'; + } else if (Types.ObjectId.isValid(payload.userId)) { + const admin = await this.adminModel.findById(payload.userId).lean(); + if (admin) { + closedByRole = 'expert'; + } + } + } + } + + if (wasAlreadyClosed) { + this.logger.warn(LogCategory.CHAT_END, 'Attempted to close already-closed session', { + sessionId: payload.sessionId, + userId: payload.userId, + onlineChatClosed: session.onlineChatClosed, + chatClosed: session.chatClosed, + assignedExpert: session.expert + }); + + // Still return success but log it as already closed + client.emit('response', createSocketResponse( + 'chatEnded', + { sessionId: payload.sessionId, alreadyClosed: true, role: closedByRole }, + 'Chat session was already closed', + HttpStatus.OK, + )); + + // Audit log with flag indicating it was already closed + this.auditLogService.logWebSocketEvent('endChat', client, { + userId: payload.userId, + resource: 'Chat', + resourceId: payload.sessionId, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + alreadyClosed: true, + onlineChatClosed: session.onlineChatClosed, + chatClosed: session.chatClosed, + role: closedByRole, + }, + }).catch(() => {}); // Fire and forget + + return; + } + + const assignments = await this.chatService.endChat(payload.sessionId); + const roomId = `room:${payload.sessionId}`; + + // Acknowledge to the emitter regardless of room membership + client.emit('response', createSocketResponse( + 'chatEnded', + { sessionId: payload.sessionId, role: closedByRole }, + 'Chat session ended', + HttpStatus.OK, + )); + + this.server.to(roomId).emit('response', createSocketResponse( + 'chatEnded', + { sessionId: payload.sessionId, role: closedByRole }, + 'Chat session ended', + HttpStatus.OK, + )); + + client.leave(roomId); + this.logger.log(LogCategory.CHAT_END, 'Chat ended', { sessionId: payload.sessionId, userId: payload.userId }); + + // Audit log for end chat + this.auditLogService.logWebSocketEvent('endChat', client, { + userId: payload.userId, + resource: 'Chat', + resourceId: payload.sessionId, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + assignmentsCount: assignments?.length || 0, + role: closedByRole, + }, + }).catch(() => {}); // Fire and forget + + // If any queued users were auto-assigned due to freed capacity, notify both sides now + if (Array.isArray(assignments) && assignments.length) { + for (const a of assignments) { + // notify expert directly by socketId + if (a.socketId) { + this.server.to(a.socketId).emit('response', createSocketResponse( + 'newUserAssigned', + { userId: a.userId, roomId: a.roomId }, + 'New user assigned to expert', + HttpStatus.OK, + )); + } + + // notify user if connected + const userSocketId = this.chatService.getConnectedSocketId(a.userId); + if (userSocketId) { + this.server.to(userSocketId).emit('response', createSocketResponse( + 'expertAssigned', + { expertId: a.expertId, roomId: a.roomId }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + } + + this.logger.log(LogCategory.USER_QUEUE, 'Auto-assigned queued user after endChat', { userId: a.userId, expertId: a.expertId, roomId: a.roomId }); + } + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onEndChat error', err as any, { sessionId: payload?.sessionId, userId: payload?.userId }); + client.emit('response', createSocketResponse( + 'endChat', + {}, + 'Failed to end chat', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('getRoomDetails') +async onGetRoomDetails( + @MessageBody() payload: { sessionId: string }, + @ConnectedSocket() client: Socket, +) { + try { + const roomKey = `room:${payload.sessionId}`; + const room = await this.chatService.getRoomDetails(roomKey); + + client.emit( + 'response', + createSocketResponse( + 'roomDetails', + { sessionId: payload.sessionId, room }, + 'Room details retrieved successfully', + HttpStatus.OK, + ), + ); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetRoomDetails error', err as any, { sessionId: payload?.sessionId }); + client.emit( + 'response', + createSocketResponse( + 'getRoomDetails', + {}, + 'Failed to fetch room details', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('getOnlineExperts') +async onGetOnlineExperts(@ConnectedSocket() client: Socket) { + try { + const experts = await this.chatService.getOnlineExperts(); + + client.emit( + 'response', + createSocketResponse( + 'onlineExperts', + { experts }, + 'Online experts retrieved successfully', + HttpStatus.OK, + ), + ); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetOnlineExperts error', err as any); + client.emit( + 'response', + createSocketResponse( + 'getOnlineExperts', + {}, + 'Failed to fetch online experts', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('getActiveRooms') +async onGetActiveRooms(@ConnectedSocket() client: Socket) { + try { + const rooms = await this.chatService.getActiveRooms(); + client.emit( + 'response', + createSocketResponse( + 'activeRooms', + { rooms }, + 'Active rooms retrieved successfully', + HttpStatus.OK, + ), + ); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'getActiveRooms error', err as any); + client.emit( + 'response', + createSocketResponse( + 'getActiveRooms', + {}, + 'Failed to fetch active rooms', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('editMessage') +async onEditMessage( + @MessageBody() + payload: { + roomId: string; + sessionId: string; + messageId: string; + newText: string; + editorId: string; + editorRole: 'user' | 'expert'; + }, + @ConnectedSocket() client: Socket, +) { + try { + this.logger.log(LogCategory.MESSAGE_EDIT, 'Edit message request', { roomId: payload.roomId, sessionId: payload.sessionId, messageId: payload.messageId, editorId: payload.editorId, role: payload.editorRole }); + // 🔒 Only experts can edit messages + if (payload.editorRole !== 'expert') { + return client.emit( + 'response', + createSocketResponse( + 'editMessage', + {}, + 'Only experts can edit messages', + HttpStatus.FORBIDDEN, + ), + ); + } + + // Update in Redis + Mongo + const updatedMessage = await this.chatService.editMessage({ + ...payload, + editorRole: 'expert', + }); + this.logger.log(LogCategory.MESSAGE_EDIT, 'Message updated', { sessionId: payload.sessionId, messageId: payload.messageId, edited: !!(updatedMessage as any)?.edited }); + + // 1) Ack back to the editor socket regardless of room membership + client.emit( + 'response', + createSocketResponse( + 'messageEdited', + { + roomId: payload.roomId, + sessionId: payload.sessionId, + message: updatedMessage, + }, + 'Message edited successfully', + HttpStatus.OK, + ), + ); + + // 2) Notify the other members in the room (exclude sender) + client.to(payload.roomId).emit( + 'response', + createSocketResponse( + 'messageEdited', + { + roomId: payload.roomId, + sessionId: payload.sessionId, + message: updatedMessage, + }, + 'Message edited successfully', + HttpStatus.OK, + ), + ); + + this.logger.log(LogCategory.MESSAGE_EDIT, 'Expert edited message', { expertId: payload.editorId, messageId: payload.messageId, roomId: payload.roomId, sessionId: payload.sessionId }); + + // Audit log for edit message + this.auditLogService.logWebSocketEvent('editMessage', client, { + userId: payload.editorId, + resource: 'Message', + resourceId: payload.messageId, + newValues: { newText: payload.newText, edited: true }, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + roomId: payload.roomId, + messageId: payload.messageId, + editorRole: payload.editorRole, + }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onEditMessage error', err as any, { sessionId: payload?.sessionId, messageId: payload?.messageId, editorId: payload?.editorId }); + + // Audit log for failed edit message + this.auditLogService.logWebSocketEvent('editMessage', client, { + userId: payload?.editorId, + resource: 'Message', + resourceId: payload?.messageId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { + error: (err as any)?.message, + sessionId: payload?.sessionId, + messageId: payload?.messageId, + }, + }).catch(() => {}); // Fire and forget + client.emit( + 'response', + createSocketResponse( + 'editMessage', + {}, + 'Failed to edit message', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('repairRedis') +async onRepairRedis(@ConnectedSocket() client: Socket) { + try { + const result = await this.chatService.repairRedisConsistency({ cleanStale: true }); + client.emit('repairRedis:done', { status: 'ok', result }); + } catch (err) { + client.emit('repairRedis:error', { error: err.message }); + } +} +@SubscribeMessage('getExpertSessions') +async onGetExpertSessions( + @MessageBody() payload: ExpertOnlinePayload, + @ConnectedSocket() client: Socket, +) { + try { + if (!payload?.expertId) { + return client.emit('response', createSocketResponse('getExpertSessions', {}, 'Missing expertId', HttpStatus.BAD_REQUEST)); + } + if (!Types.ObjectId.isValid(payload.expertId) || /@/.test(String(payload.expertId))) { + return client.emit('response', createSocketResponse('getExpertSessions', {}, 'Invalid expertId', HttpStatus.BAD_REQUEST)); + } + + const data = await this.chatService.getExpertActiveSessions(payload.expertId); + + client.emit('response', createSocketResponse( + 'expertSessions', + data, + 'Expert active sessions retrieved', + HttpStatus.OK, + )); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetExpertSessions error', err as any, { expertId: payload?.expertId }); + client.emit('response', createSocketResponse('getExpertSessions', {}, 'Failed to fetch expert sessions', HttpStatus.INTERNAL_SERVER_ERROR)); + } +} + +@SubscribeMessage('markSeen') +async onMarkSeen( + @MessageBody() payload: { sessionId: string; viewerRole: 'user' | 'expert' }, + @ConnectedSocket() client: Socket, +) { + try { + if (!payload?.sessionId || !payload?.viewerRole) { + return client.emit('response', createSocketResponse('markSeen', {}, 'Missing payload', HttpStatus.BAD_REQUEST)); + } + + // Normalize viewerRole to lowercase to handle case-insensitive input + const normalizedViewerRole = payload.viewerRole.toLowerCase() as 'user' | 'expert'; + if (normalizedViewerRole !== 'user' && normalizedViewerRole !== 'expert') { + return client.emit('response', createSocketResponse('markSeen', {}, 'Invalid viewerRole. Must be "user" or "expert"', HttpStatus.BAD_REQUEST)); + } + + const res = await this.chatService.markMessagesSeen(payload.sessionId, normalizedViewerRole); + await this.chatService.cancelPendingExpertTimer(payload.sessionId); + await this.client().set(`session:${payload.sessionId}:expertSeen`, 'true', 'EX', 60 * 60 * 24); + if (normalizedViewerRole === 'expert') { + // bump expert last active where possible; infer from room + try { + const roomId = `room:${payload.sessionId}`; + const room = await this.chatService.getRoomDetails(roomId); + if ((room as any)?.expert?._id || (room as any)?.expertId) { + await this.chatService.refreshExpertLastActive(String((room as any)?.expert?._id || (room as any)?.expertId)); + } + } catch {} + } + // Ack back to the client that invoked markSeen + client.emit('response', createSocketResponse( + 'markSeenAck', + { sessionId: payload.sessionId, updated: res.updated }, + 'Messages marked as seen', + HttpStatus.OK, + )); + + // Notify the other participant in the room (if joined) about updated seen counts + const roomId = `room:${payload.sessionId}`; + this.server.to(roomId).emit('response', createSocketResponse( + 'messagesSeenUpdated', + { sessionId: payload.sessionId, viewerRole: normalizedViewerRole, updated: res.updated }, + 'Seen status updated', + HttpStatus.OK, + )); + + // Audit log for mark seen + this.auditLogService.logWebSocketEvent('markSeen', client, { + resource: 'Message', + resourceId: payload.sessionId, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + viewerRole: normalizedViewerRole, + updated: res.updated, + }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onMarkSeen error', err as any, { sessionId: payload?.sessionId, viewerRole: payload?.viewerRole }); + + // Audit log for failed mark seen + this.auditLogService.logWebSocketEvent('markSeen', client, { + resource: 'Message', + resourceId: payload?.sessionId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { + error: (err as any)?.message, + sessionId: payload?.sessionId, + viewerRole: payload?.viewerRole, + }, + }).catch(() => {}); // Fire and forget + client.emit('response', createSocketResponse('markSeen', {}, 'Failed to mark seen', HttpStatus.INTERNAL_SERVER_ERROR)); + } +} + + + + + private async handleTransferResult( + result: TransferChatResult, + requestingClient: Socket, + ): Promise { + if (result.status === 'queued') { + requestingClient.emit( + 'response', + createSocketResponse( + 'transferChat', + { + status: 'queued', + sessionId: result.sessionId, + position: result.position, + preferredExpertId: result.preferredExpertId, + }, + 'Transfer queued until the selected expert has capacity', + HttpStatus.OK, + ), + ); + const userSocketId = this.chatService.getConnectedSocketId(result.userId); + if (userSocketId) { + this.server.to(userSocketId).emit( + 'response', + createSocketResponse( + 'queued', + { + position: result.position, + sessionId: result.sessionId, + waitingForTransfer: true, + preferredExpertId: result.preferredExpertId, + }, + 'Waiting for an expert to take your chat', + HttpStatus.SERVICE_UNAVAILABLE, + ), + ); + } + this.server.to(result.roomId).emit( + 'response', + createSocketResponse( + 'chatTransferred', + { + sessionId: result.sessionId, + status: 'queued', + fromExpertId: result.fromExpertId, + preferredExpertId: result.preferredExpertId, + mode: result.mode, + }, + 'Chat transfer queued', + HttpStatus.OK, + ), + ); + return; + } + + requestingClient.emit( + 'response', + createSocketResponse( + 'transferChat', + { + status: 'transferred', + sessionId: result.sessionId, + toExpertId: result.toExpertId, + }, + 'Chat transferred successfully', + HttpStatus.OK, + ), + ); + + await this.notifyExpertReassigned( + result.sessionId, + result.fromExpertId, + result.toExpertId, + result.userId, + ); + } + + private async notifyExpertReassigned( + sessionId: string, + oldExpertId: string | null, + newExpertId: string | null, + userId?: string, + ): Promise { + const roomId = `room:${sessionId}`; + let expertInfo: { family: string } | null = null; + if (newExpertId && Types.ObjectId.isValid(newExpertId)) { + const admin = await this.adminModel.findById(newExpertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + + this.server.to(roomId).emit( + 'response', + createSocketResponse( + 'chatTransferred', + { + sessionId, + oldExpertId, + newExpertId, + status: 'transferred', + ...(expertInfo ? { expert: expertInfo } : {}), + }, + 'Chat has been transferred to another expert', + HttpStatus.OK, + ), + ); + + if (userId) { + const userSocketId = this.chatService.getConnectedSocketId(userId); + if (userSocketId) { + this.server.sockets.sockets.get(userSocketId)?.join(roomId); + } + } + + if (newExpertId) { + const socketId = + (await this.chatService.getExpertSocketIdFromRedis(newExpertId)) || + (await this.chatService.getOnlineExperts()).find( + (e: any) => e.expertId === newExpertId, + )?.socketId; + if (socketId) { + this.server.sockets.sockets.get(socketId)?.join(roomId); + this.server.to(socketId).emit( + 'response', + createSocketResponse( + 'newUserAssigned', + { userId, roomId, sessionId, expertId: newExpertId, fromTransfer: true }, + 'Chat transferred to you', + HttpStatus.OK, + ), + ); + } + } + + if (oldExpertId) { + const oldSocketId = await this.chatService.getExpertSocketIdFromRedis(oldExpertId); + if (oldSocketId) { + this.server.to(oldSocketId).emit( + 'response', + createSocketResponse( + 'sessionTransferredAway', + { sessionId, roomId }, + 'This chat was transferred to another expert', + HttpStatus.OK, + ), + ); + } + } + } + + // ----- Generic Notify ----- + notifyClient(clientId: string, payload: NotificationPayload) { + this.server.to(clientId).emit('response', createSocketResponse( + 'notify', + payload, + 'Notification received', + HttpStatus.OK, + )); + } + + broadcastError(client: Socket, message: string) { + client.emit('response', createSocketResponse( + 'error', + { code: 'GENERAL_ERROR' }, + message, + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } +} \ No newline at end of file diff --git a/src/socket/support-management/chat.service.ts b/src/socket/support-management/chat.service.ts new file mode 100644 index 0000000..d5b86aa --- /dev/null +++ b/src/socket/support-management/chat.service.ts @@ -0,0 +1,4218 @@ +import { Injectable, Logger, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Socket } from 'socket.io'; +import { v4 as uuidv4 } from 'uuid'; +import { + JoinRoomPayload, + MessageReplyToSnapshot, + RequestExpertPayload, + SendMessagePayload, + UserRole, +} from './dto/eventPayloads.dto'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { AdminModel } from 'src/database/model/admin.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { ChatMessageAttachmentModel } from 'src/database/model/chat-message-attachment.model'; +import { RedisService } from "src/common/helpers/redis.service"; +import { TimeHelper } from "src/common/tools/time-helper"; + + + +interface MessageRedis { + messageId: string; // will be ObjectId string + message: string; + sender: UserRole; + react?: string; + createdAt: [string, string]; + createdISO: string; + edited?: boolean; + seenBy?: { user?: boolean; expert?: boolean }; + replyTo?: MessageReplyToSnapshot; + messageType?: 'text' | 'image' | 'voice' | 'document'; + voiceDurationSec?: number; + voiceMimeType?: string; + /** Non-voice attachments (image/document). Voice continues to use voiceMimeType for backward compatibility. */ + mimeType?: string; +} + +interface RoomRedisData { + roomId: string; + sessionId: string; + userId: string; + expertId: string | null; + createdAt: [string, string]; + messages: MessageRedis[]; + isActive: boolean; +} + +export interface TransferCandidateDto { + expertId: string; + name: string; + family: string; + capacityLeft: number; + activeSessions: number; + maxSessions: number; +} + +export type TransferChatResult = + | { + status: 'transferred'; + sessionId: string; + roomId: string; + userId: string; + fromExpertId: string; + toExpertId: string; + toExpertSocketId: string | null; + mode: 'selective' | 'random'; + reason?: string; + } + | { + status: 'queued'; + sessionId: string; + roomId: string; + userId: string; + fromExpertId: string; + preferredExpertId: string; + position: number; + mode: 'selective' | 'random'; + reason?: string; + }; + +interface WaitingQueueEntry { + userId: string; + sessionId: string; + queuedAt: string; + preferredExpertId?: string; + source?: 'transfer' | 'request'; +} + +interface ExpertRedisData { + expertId: string; + socketId: string; + activeSessions: number; + maxSessions: number; + sessionIds: string[]; // current session ids assigned to this expert + isOnline:boolean; +} + +@Injectable() +export class ChatService { + private readonly logger = new Logger(ChatService.name); + private readonly ONLINE_EXPERTS_KEY = 'onlineExperts'; // set of expertIds + private readonly EXPERT_KEY_PREFIX = 'expert:'; // full key = expert: + private readonly WAITING_QUEUE_KEY = 'waitingQueue'; // list of userIds + private readonly ROOMS_HASH_KEY = 'rooms:active'; // hash: roomId -> JSON(RoomRedisData) + private expertSockets = new Map(); // expertId → socketId + private readonly MAX_SESSIONS_PER_EXPERT = 4; + private WAITING_USERS_KEY = 'waiting_users'; + // In-memory mapping (optional fast lookup). We still use Redis as source of truth. + private connectedClients = new Map(); // userId -> socketId (keeps track of connected sockets in this instance) + private readonly PENDING_PREFIX = 'pendingExpert:'; + private readonly INACTIVE_PREFIX = 'inactiveChat:'; + + // Feature flag: if true and environment supports it, we could use RedisJSON arrays. We still default to Redis Sets to avoid hash overwrites + private readonly USE_REDIS_JSON_SESSIONS = String(process.env.USE_REDIS_JSON_SESSIONS || '').toLowerCase() === 'true'; + private readonly REPLY_PREVIEW_MAX_LEN = 200; + + private normalizeCreatedISO(value: any): string { + if (!value) return new Date().toISOString(); + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string') { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); + } + try { + return new Date(value).toISOString(); + } catch { + return new Date().toISOString(); + } + } + + private normalizeReplyToFromDoc(raw: any): MessageReplyToSnapshot | undefined { + if (!raw || !raw.messageId) return undefined; + return { + messageId: String(raw.messageId?.toString?.() ?? raw.messageId), + textPreview: raw.textPreview ?? '', + sender: raw.sender, + createdISO: raw.createdISO != null ? this.normalizeCreatedISO(raw.createdISO) : undefined, + unavailable: !!raw.unavailable, + }; + } + + private findMessageInSessionList(list: any[] | undefined, messageId: string) { + if (!list?.length) return null; + for (const m of list) { + const mid = String(m.messageId?.toString?.() ?? m.messageId); + if (mid !== messageId) continue; + const text: string = m.message ?? m.text ?? ''; + return { text, sender: m.sender as UserRole, createdISO: this.normalizeCreatedISO(m.createdISO) }; + } + return null; + } + + private buildReplyToSnapshot( + replyToMessageId: string, + room: RoomRedisData, + statusDoc: any, + ): MessageReplyToSnapshot { + const fromRoom = this.findMessageInSessionList(room?.messages, replyToMessageId); + const fromMongo = !fromRoom ? this.findMessageInSessionList(statusDoc?.messages, replyToMessageId) : null; + const found = fromRoom || fromMongo; + if (!found) { + throw new Error('Reply target message not found in this session'); + } + const { text, sender, createdISO } = found; + const previewBase = + typeof text === 'string' && + (text.includes('/uploads/voices/') || + text.includes('/voices/') || + text.includes('/chats/')) + ? '[Voice message]' + : text; + const textPreview = + previewBase.length > this.REPLY_PREVIEW_MAX_LEN + ? `${previewBase.slice(0, this.REPLY_PREVIEW_MAX_LEN)}…` + : previewBase; + return { + messageId: replyToMessageId, + textPreview, + sender, + createdISO, + unavailable: false, + }; + } + + private enrichReplyToAvailability(messages: T[]): T[] { + const ids = new Set(messages.map((m) => String(m.messageId))); + return messages.map((m) => { + if (!m.replyTo?.messageId) return m; + const refId = String(m.replyTo.messageId); + if (ids.has(refId)) { + if (m.replyTo.unavailable) { + return { ...m, replyTo: { ...m.replyTo, unavailable: false } } as T; + } + return m; + } + return { ...m, replyTo: { ...m.replyTo, unavailable: true, messageId: refId } } as T; + }); + } + + private expertSessionsKey(expertId: string) { + return `${this.EXPERT_KEY_PREFIX}${expertId}:sessions`; + } + + private async getExpertSessionIdsFromSet(expertId: string): Promise { + const client = this.client(); + const setKey = this.expertSessionsKey(expertId); + const members = await client.smembers(setKey); + return Array.isArray(members) ? [...new Set(members.filter(Boolean))] : []; + } + + /** + * Filters out closed sessions from a list of session IDs by checking MongoDB + * This ensures we don't sync stale/closed sessions into onlineExperts + */ + private async filterActiveSessions(sessionIds: string[]): Promise { + if (sessionIds.length === 0) return []; + + try { + // Batch check all sessions in one query + const sessions = await this.sessionModel.find({ + _id: { $in: sessionIds.map(id => new Types.ObjectId(id)) }, + }).select('_id onlineChatClosed chatClosed').lean(); + + const closedSessionIds = new Set( + sessions + .filter(s => (s as any).onlineChatClosed || (s as any).chatClosed) + .map(s => String(s._id)) + ); + + const activeSessionIds = sessionIds.filter(id => !closedSessionIds.has(id)); + + // If we found closed sessions, log them for debugging + if (closedSessionIds.size > 0) { + this.logger.warn( + `[filterActiveSessions] Filtered out ${closedSessionIds.size} closed session(s): ${Array.from(closedSessionIds).join(', ')}` + ); + } + + return activeSessionIds; + } catch (err) { + this.logger.error('[filterActiveSessions] Error filtering sessions', err as any); + // On error, return all (conservative - don't drop valid sessions) + return sessionIds; + } + } + + private async addSessionToExpertSet(expertId: string, sessionId: string): Promise { + const client = this.client(); + const setKey = this.expertSessionsKey(expertId); + this.logger.log(`[SETS] SADD ${setKey} ${sessionId}`); + await client.sadd(setKey, sessionId); + const count = await client.scard(setKey); + this.logger.log(`[SETS] SCARD ${setKey} => ${count}`); + return count; + } + + private async removeSessionFromExpertSet(expertId: string, sessionId: string): Promise { + const client = this.client(); + const setKey = this.expertSessionsKey(expertId); + this.logger.log(`[SETS] SREM ${setKey} ${sessionId}`); + await client.srem(setKey, sessionId); + const count = await client.scard(setKey); + this.logger.log(`[SETS] SCARD ${setKey} => ${count}`); + return count; + } + + private async updateOnlineExpertHash(expertId: string, patch: Partial & { includeSessionIdsFromSet?: boolean }) { + const client = this.client(); + const before = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + this.logger.log(`[HSET][BEFORE] onlineExperts[${expertId}] = ${before}`); + + // Merge patch onto existing (best-effort) + let next: any = {}; + try { + next = before ? JSON.parse(before) : {}; + } catch { + next = {}; + } + + // Optionally refresh sessionIds from set to avoid drift + if (patch.includeSessionIdsFromSet) { + const sessionIds = await this.getExpertSessionIdsFromSet(expertId); + // CRITICAL: Filter out closed sessions before syncing to hash + // This prevents stale/closed sessions from being added back to onlineExperts + const activeSessionIds = await this.filterActiveSessions(sessionIds); + next.sessionIds = activeSessionIds; + next.activeSessions = activeSessionIds.length; + + // If we filtered out closed sessions, remove them from the SET as well + if (activeSessionIds.length < sessionIds.length) { + const closedSessionIds = sessionIds.filter(id => !activeSessionIds.includes(id)); + for (const closedSessionId of closedSessionIds) { + try { + await this.removeSessionFromExpertSet(expertId, closedSessionId); + this.logger.log(`[updateOnlineExpertHash] Removed closed session ${closedSessionId} from expert ${expertId} SET`); + } catch (err) { + this.logger.warn(`[updateOnlineExpertHash] Failed to remove closed session ${closedSessionId} from SET`, err as any); + } + } + } + } + + // Apply other fields from patch + Object.assign(next, patch); + delete next.includeSessionIdsFromSet; + + await client.hset(this.ONLINE_EXPERTS_KEY, expertId, JSON.stringify(next)); + const after = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + this.logger.log(`[HSET][AFTER] onlineExperts[${expertId}] = ${after}`); + } + + // ============================= + // ===== VALIDATION HELPERS ==== + // ============================= + + /** + * Validates userId exists in User collection + */ + private async validateUserId(userId: string): Promise { + if (!userId || !Types.ObjectId.isValid(userId)) { + this.logger.warn(`Invalid userId format: ${userId}`); + return false; + } + + try { + const userExists = await this.userModel.exists({ _id: userId }); + if (!userExists) { + this.logger.warn(`UserId does not exist in User collection: ${userId}`); + return false; + } + return true; + } catch (err) { + this.logger.error(`Error validating userId ${userId}:`, err); + return false; + } + } + + /** + * Validates sessionId exists and optionally checks if userId matches + */ + private async validateSessionId(sessionId: string, expectedUserId?: string): Promise<{ valid: boolean; session?: any }> { + if (!sessionId || !Types.ObjectId.isValid(sessionId)) { + this.logger.warn(`Invalid sessionId format: ${sessionId}`); + return { valid: false }; + } + + try { + const session = await this.sessionModel.findById(sessionId).lean(); + if (!session) { + this.logger.warn(`SessionId does not exist in Session collection: ${sessionId}`); + return { valid: false }; + } + + // If expectedUserId is provided, verify it matches + if (expectedUserId) { + const sessionUserId = session?.userId?.toString?.(); + if (sessionUserId !== expectedUserId) { + this.logger.warn(`Session ${sessionId} userId mismatch: expected ${expectedUserId}, got ${sessionUserId}`); + return { valid: false }; + } + } + + return { valid: true, session }; + } catch (err) { + this.logger.error(`Error validating sessionId ${sessionId}:`, err); + return { valid: false }; + } + } + + /** + * Validates and normalizes expertId from various formats (ObjectId, username, email) + */ + private async normalizeExpertId(rawExpert: any, sessionId: string): Promise { + if (!rawExpert) { + return null; + } + + const expertStr = String(rawExpert); + + // Case 1: Already a valid ObjectId + if (Types.ObjectId.isValid(expertStr)) { + try { + const expertExists = await this.adminModel.exists({ _id: expertStr }); + if (expertExists) { + return expertStr; + } else { + this.logger.warn(`Session ${sessionId} references non-existent expert ObjectId: ${expertStr}`); + return null; + } + } catch (err) { + this.logger.error(`Error validating expert ObjectId ${expertStr} for session ${sessionId}:`, err); + return null; + } + } + + // Case 2: Username or email - look it up + if (typeof expertStr === 'string' && expertStr.length > 0) { + try { + const admin = await this.adminModel + .findOne({ $or: [{ username: expertStr }, { email: expertStr }] }) + .select('_id') + .lean(); + + if (admin?._id) { + const normalizedId = String((admin as any)._id); + this.logger.log(`Normalized expertId for session ${sessionId}: ${expertStr} -> ${normalizedId}`); + return normalizedId; + } else { + this.logger.warn(`Session ${sessionId} references non-existent expert: ${expertStr}`); + return null; + } + } catch (err) { + this.logger.error(`Error normalizing expertId for session ${sessionId}:`, err); + return null; + } + } + + return null; + } + + /** + * Validates userId from session and returns it if valid + */ + private async validateAndGetUserId(session: any, sessionId: string): Promise { + if (!session?.userId) { + this.logger.warn(`Session ${sessionId} has no userId`); + return null; + } + + const userId = session.userId?.toString?.(); + if (!userId || !Types.ObjectId.isValid(userId)) { + this.logger.warn(`Session ${sessionId} has invalid userId: ${userId}`); + return null; + } + + // Verify user exists in database + const isValid = await this.validateUserId(userId); + if (!isValid) { + this.logger.warn(`Session ${sessionId} references non-existent user: ${userId}`); + return null; + } + + return userId; + } + + // Ensure canonical mapping for expert/session exists and sync the hash entry + async ensureExpertSessionMapping(expertId: string, sessionId: string) { + try { + await this.addSessionToExpertSet(expertId, sessionId); + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn('ensureExpertSessionMapping failed', err as any); + } + } + + /** + * Logs a reassignment event to the reassigned_logs collection + * @param sessionId - The session ID being reassigned + * @param userId - The user ID who owns the session + * @param fromExpertEmail - The email of the expert the session is being reassigned from + * @param toExpertEmail - The email of the expert the session is being reassigned to, or null if no expert available + */ + private async logReassignment( + sessionId: string, + userId: string, + fromExpertEmail: string, + toExpertEmail: string | null, + options?: { + source?: 'manual' | 'auto'; + mode?: 'selective' | 'random'; + reason?: string; + }, + ): Promise { + try { + await this.reassignedLogsModel.create({ + sessionId: new Types.ObjectId(sessionId), + userId: new Types.ObjectId(userId), + from: fromExpertEmail, + to: toExpertEmail, + source: options?.source ?? 'auto', + mode: options?.mode, + reason: options?.reason, + }); + this.logger.log( + `[logReassignment] Logged reassignment for session ${sessionId}: ${fromExpertEmail} -> ${toExpertEmail || 'null'}`, + ); + } catch (err) { + this.logger.error(`[logReassignment] Failed to log reassignment for session ${sessionId}`, err as any); + } + } + + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(SessionModel.name) + private readonly sessionModel: Model, + @InjectModel(UserModel.name) + private readonly userModel: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogsModel: Model, + @InjectModel(ChatMessageAttachmentModel.name) + private readonly chatAttachmentModel: Model, + private readonly redisService: RedisService, + ) {} + private client() { + return this.redisService.getClient(); + } + + + // ============================= + // ===== CONNECTION LOGIC ====== + // ============================= + + async registerClient(payload: JoinRoomPayload, socketId: string) { + try { + if (!payload?.userId) return; + this.connectedClients.set(payload.userId, socketId); + this.logger.log(`registerClient: ${payload.userId} -> ${socketId}`); + } catch (err) { + this.logger.error('registerClient error', err as any); + } + } + + async unregisterClient(userId: string) { + try { + this.connectedClients.delete(userId); + // Also remove from waiting queue if present + await this.removeUserFromQueue(userId); + this.logger.log(`unregisterClient: ${userId} removed`); + } catch (err) { + this.logger.error('unregisterClient error', err as any); + } + } + + // Expose connected user's socket id for server-driven notifications/room joins + getConnectedSocketId(userId: string): string | null { + try { + return this.connectedClients.get(userId) || null; + } catch { + return null; + } + } + + // Add a user to the waiting queue +async addWaitingUser(userId: string, sessionId: string) { + const client = this.client(); + const userData = JSON.stringify({ + userId, + sessionId, + timestamp: Date.now(), + }); + await client.hset(this.WAITING_USERS_KEY, sessionId, userData); + return true; +} + +// Remove a user from the waiting queue +async removeWaitingUser(sessionId: string) { + const client = this.client(); + await client.hdel(this.WAITING_USERS_KEY, sessionId); +} + + + async handleDisconnect(client: Socket) { + // When a socket disconnects, find who it belonged to + const userId = [...this.connectedClients.entries()].find( + ([, socket]) => socket === client.id, + )?.[0]; + + if (!userId) return; + + this.logger.warn(`Socket disconnected: ${client.id} (${userId})`); + this.connectedClients.delete(userId); + + // 1. Check if user is in waiting queue BEFORE removing (to identify queued sessions) + const redisClient = this.client(); + let queuedSessionIds: string[] = []; + try { + const queue = await redisClient.lrange(this.WAITING_QUEUE_KEY, 0, -1); + queuedSessionIds = queue + .map((entry) => { + try { + const parsed = JSON.parse(entry); + if (parsed.userId === userId && parsed.sessionId) { + return parsed.sessionId; + } + } catch { + // Skip invalid entries + } + return null; + }) + .filter((id): id is string => id !== null); + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to check waiting queue`, err as any); + } + + // 2. Auto-close sessions if user was queued (not yet assigned to expert) + if (queuedSessionIds.length > 0) { + try { + for (const sessionId of queuedSessionIds) { + // Verify session is not yet connected to expert before closing + try { + const session = await this.sessionModel.findById(sessionId).select('connectedToExpert onlineChatClosed userId').lean(); + if (session && String(session.userId) === userId && !session.connectedToExpert && !session.onlineChatClosed) { + // User was queued and disconnected - close the session + this.logger.log(`[handleDisconnect] Auto-closing queued session ${sessionId} for disconnected user ${userId}`); + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + // Also remove from waiting_users hash if present + await redisClient.hdel(this.WAITING_USERS_KEY, sessionId); + this.logger.log(`[handleDisconnect] Closed queued session ${sessionId} for disconnected user`); + } + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to close queued session ${sessionId}`, err as any); + } + } + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to auto-close queued sessions`, err as any); + } + } + + // 3. Clean up waiting queue - remove user from queue + try { + const removed = await this.removeUserFromQueue(userId); + if (removed > 0) { + this.logger.log(`[handleDisconnect] Removed user ${userId} from waiting queue (${removed} entries)`); + } + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to remove user from queue`, err as any); + } + + // Also check if expert disconnected + const expertId = [...this.expertSockets.entries()].find( + ([, socket]) => socket === client.id, + )?.[0]; + + if (expertId) { + this.expertSockets.delete(expertId); + try { + const r = this.client(); + await r.set( + `expert:${expertId}:disconnectedAt`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + this.logger.warn( + `Expert socket disconnected (grace period before auto-reassign): ${expertId}`, + ); + } catch (err) { + this.logger.error('Failed to set expert disconnectedAt', err as any); + } + } + } + + // ============================= + // ===== EXPERT AVAILABILITY === + // ============================= + + async setExpertOnline( + expertId: string, + socketId: string, + ): Promise< + Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + }> + > { + try { + const client = this.client(); + + // Use includeSessionIdsFromSet to automatically filter closed sessions + // This ensures we don't sync stale/closed sessions when expert comes online + this.logger.log(`[setExpertOnline] Writing onlineExperts[${expertId}] with filtered sessions from SET`); + await this.updateOnlineExpertHash(expertId, { + expertId, + socketId, + maxSessions: this.MAX_SESSIONS_PER_EXPERT, + includeSessionIdsFromSet: true, // This will filter closed sessions automatically + isOnline: true, + }); + + this.expertSockets.set(expertId, socketId); + await client.del(`expert:${expertId}:disconnectedAt`); + + this.logger.log(`setExpertOnline: ${expertId} (socket ${socketId})`); + + // After expert comes online, try to assign queued users (if any) + const assignments = await this.assignFromQueue(); + // Touch expert last active + try { + await this.refreshExpertLastActive(expertId); + } catch {} + return assignments; + } catch (err) { + this.logger.error('setExpertOnline error', err as any); + throw err; + } + } + + async setExpertOffline(expertId: string) { + try { + const client = this.client(); + + const sessionIds = await this.getExpertSessionIdsFromSet(expertId); + const activeSessionIds = await this.filterActiveSessions(sessionIds); + if (activeSessionIds.length > 0) { + throw new ForbiddenException( + 'Cannot go offline while you have active chat sessions. Transfer or end them first.', + ); + } + + await client.del(`expert:${expertId}:disconnectedAt`); + + // remove from online set / hash + await client.hdel(this.ONLINE_EXPERTS_KEY, expertId); + + this.expertSockets.delete(expertId); + this.logger.log(`setExpertOffline: ${expertId}`); + } catch (err) { + this.logger.error('setExpertOffline error', err as any); + throw err; + } + } + + getExpertSocketIdFromRedis(expertId: string): Promise { + return this.client() + .hget(this.ONLINE_EXPERTS_KEY, expertId) + .then((raw) => { + if (!raw) return null; + try { + return (JSON.parse(raw) as ExpertRedisData).socketId || null; + } catch { + return null; + } + }) + .catch(() => null); + } + + async getOnlineExperts() { + const client = this.client(); + try { + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + + if (Object.keys(entries).length === 0) { + return []; + } + + const experts: ExpertRedisData[] = Object.values(entries).map((v) => { + try { + return JSON.parse(v as string) as ExpertRedisData; + } catch { + return null as any; + } + }).filter(Boolean); + + // Filter for experts who are explicitly marked as online + const onlineExperts = experts.filter((e) => e.isOnline); + this.logger.log(`Found ${onlineExperts.length} online experts after filtering.`); + + // Fetch additional details from adminModel for each online expert + const enrichedExperts = await Promise.all( + onlineExperts.map(async (expertData) => { + this.logger.log(`Fetching admin details for expertId: ${expertData.expertId}`); + const admin = await this.adminModel + .findById(expertData.expertId) + .select('_id name family role') + .lean(); + + return { + ...expertData, + name: admin?.name, + family: admin?.family, + role: admin?.role, + }; + }), + ); + + return enrichedExperts; + } catch (err) { + this.logger.error('getOnlineExperts error', err as any); + return []; + } + } + + async getActiveRooms() { + const client = this.client(); + const keys = await client.hkeys(this.ROOMS_HASH_KEY); + const all = await Promise.all( + keys.map(async (key) => { + const data = await client.hget(this.ROOMS_HASH_KEY, key); + try { + return JSON.parse(data); + } catch { + return null; + } + }), + ); + return all + .filter((r) => r && r.isActive) + .map((r) => ({ + ...r, + // normalize unknown userId if available from Mongo + userId: r.userId && r.userId !== 'unknown-user' ? r.userId : r.userId, + })); + } + + async getUserActiveSessions(userId: string): Promise> { + const client = this.client(); + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + if (!roomsRaw || Object.keys(roomsRaw).length === 0) return []; + + const sessions: Array<{ sessionId: string; roomId: string; expertId: string }> = []; + + for (const [roomId, raw] of Object.entries(roomsRaw)) { + try { + const room = JSON.parse(raw as string) as RoomRedisData; + // Check strict equality for userId + // Note: we accept any active state (undefined or true), only explicit false is excluded + if (room?.userId === userId && (room as any)?.isActive !== false) { + sessions.push({ sessionId: String(room.sessionId), roomId, expertId: String(room.expertId) }); + } + } catch {} + } + return sessions; + } + + // ======================== + // User Queue Management + // ======================== + + async enqueueUser( + userId: string, + sessionId: string, + options?: { preferredExpertId?: string; source?: 'transfer' | 'request' }, + ) { + try { + const client = this.client(); + + // Use Redis transaction to atomically check and add + // This prevents race conditions where multiple requests check simultaneously + const raw = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + const userExists = raw.some((entry) => { + try { + const parsed = JSON.parse(entry) as WaitingQueueEntry; + return parsed.userId === userId; + } catch { + return false; + } + }); + + if (!userExists) { + const queuedData: WaitingQueueEntry = { + userId, + sessionId, + queuedAt: new Date().toISOString(), + ...(options?.preferredExpertId + ? { preferredExpertId: options.preferredExpertId } + : {}), + ...(options?.source ? { source: options.source } : {}), + }; + // Use Redis transaction (MULTI/EXEC) for atomic operation + const multi = client.multi(); + multi.lrange(this.WAITING_QUEUE_KEY, 0, -1); + multi.rpush(this.WAITING_QUEUE_KEY, JSON.stringify(queuedData)); + const results = await multi.exec(); + + // Double-check after transaction to ensure no duplicate was added + const afterRaw = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + const duplicates = afterRaw.filter((entry) => { + try { + const parsed = JSON.parse(entry); + return parsed.userId === userId; + } catch { + return false; + } + }); + + if (duplicates.length > 1) { + // Remove duplicates, keep only the first one + this.logger.warn(`Found ${duplicates.length} duplicate entries for userId ${userId}, removing duplicates`); + for (let i = 1; i < duplicates.length; i++) { + await client.lrem(this.WAITING_QUEUE_KEY, 1, duplicates[i]); + } + } + + this.logger.log(`enqueueUser: ${userId} with sessionId ${sessionId} queued at ${queuedData.queuedAt}`); + } else { + this.logger.log(`enqueueUser: ${userId} already queued`); + } + } catch (err) { + this.logger.error('enqueueUser error', err as any); + throw err; + } + } + + /** + * Removes all entries for a userId from the waiting queue + */ + async removeUserFromQueue(userId: string): Promise { + try { + const client = this.client(); + const raw = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + let removed = 0; + + // Remove all entries matching userId + for (const entry of raw) { + try { + const parsed = JSON.parse(entry); + if (parsed.userId === userId) { + await client.lrem(this.WAITING_QUEUE_KEY, 1, entry); + removed++; + } + } catch { + // Skip invalid entries + } + } + + if (removed > 0) { + this.logger.log(`removeUserFromQueue: removed ${removed} entries for ${userId}`); + } + return removed; + } catch (err) { + this.logger.error('removeUserFromQueue error', err as any); + return 0; + } + } + + async dequeueUser(): Promise { + try { + const client = this.client(); + const val = await client.lpop(this.WAITING_QUEUE_KEY); + return val; + } catch (err) { + this.logger.error('dequeueUser error', err as any); + return null; + } + } + + // ============================= + // ===== MATCHING USERS ======== + // ============================= + + async assignExpert( + payload: RequestExpertPayload, + isBusinessHours: boolean = false, + options?: { + preferredExpertId?: string; + excludeExpertId?: string; + }, + ): Promise< + | { + status: 'assigned'; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + } + | { status: 'queued' } + | { status: 'no_experts_available' } + > { + const { userId, sessionId } = payload; + + try { + this.logger.log(`[assignExpert] Using PENDING_EXPERT_SECONDS=${process.env.PENDING_EXPERT_SECONDS || '300'}`); + + // Validate userId exists + const isUserIdValid = await this.validateUserId(userId); + if (!isUserIdValid) { + throw new Error(`Invalid userId: ${userId} does not exist`); + } + + // Validate sessionId exists and userId matches + const sessionValidation = await this.validateSessionId(sessionId, userId); + if (!sessionValidation.valid) { + throw new Error(`Invalid sessionId: ${sessionId} does not exist or userId mismatch`); + } + + const client = this.client(); + + // fetch all online experts + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (Object.keys(entries).length === 0) { + // During business hours, if no experts are online, return special status + // Outside business hours, this case shouldn't happen (gateway handles it) + if (isBusinessHours) { + this.logger.warn('assignExpert: no online experts during business hours'); + return { status: 'no_experts_available' }; + } + // Fallback: queue if not in business hours (shouldn't reach here normally) + await this.enqueueUser(userId, sessionId); + this.logger.warn('assignExpert: no online experts, user queued'); + return { status: 'queued' }; + } + + // parse all experts safely + const experts: ExpertRedisData[] = Object.values(entries) + .map((v) => { + try { + return JSON.parse(v as string) as ExpertRedisData; + } catch { + return null as any; + } + }) + .filter(Boolean); + + // filter available ones + let available = experts.filter( + (e) => e.isOnline && (e.activeSessions || 0) < (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + + if (options?.excludeExpertId) { + available = available.filter((e) => e.expertId !== options.excludeExpertId); + } + + if (options?.preferredExpertId) { + const preferred = available.find((e) => e.expertId === options.preferredExpertId); + if (!preferred) { + this.logger.warn( + `assignExpert: preferred expert ${options.preferredExpertId} unavailable, user queued`, + ); + await this.enqueueUser(userId, sessionId, { + preferredExpertId: options.preferredExpertId, + source: 'transfer', + }); + return { status: 'queued' }; + } + available = [preferred]; + } + + if (!available.length) { + await this.enqueueUser(userId, sessionId); + this.logger.warn('assignExpert: no experts with capacity, user queued'); + return { status: 'queued' }; + } + + // choose least busy + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + const chosen = available[0]; + + // CRITICAL: Remove session from all expert SETs first to prevent duplicates + // This handles cases where the session might already be assigned to another expert + const expertsToSync: string[] = []; + for (const expert of experts) { + if (expert.expertId !== chosen.expertId) { + try { + const setKey = this.expertSessionsKey(expert.expertId); + const isMember = await client.sismember(setKey, sessionId); + if (isMember) { + await client.srem(setKey, sessionId); + expertsToSync.push(expert.expertId); + this.logger.log(`[assignExpert] Removed session ${sessionId} from expert ${expert.expertId} SET before reassignment`); + } + } catch (err) { + this.logger.warn(`[assignExpert] Failed to check/remove session ${sessionId} from expert ${expert.expertId} SET`, err as any); + } + } + } + + // Update canonical session set for chosen expert + const newCount = await this.addSessionToExpertSet(chosen.expertId, sessionId); + + // Sync the hash with sessions from set and chosen socketId + this.logger.log(`[assignExpert] Sync hash with set for expert ${chosen.expertId}, expected count: ${newCount}`); + await this.updateOnlineExpertHash(chosen.expertId, { + includeSessionIdsFromSet: true, + socketId: chosen.socketId, + expertId: chosen.expertId, + maxSessions: chosen.maxSessions, + isOnline: true, + }); + + // Sync all experts that had the session removed + for (const expertId of expertsToSync) { + try { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`[assignExpert] Failed to sync expert ${expertId} hash after session removal`, err as any); + } + } + + // Check if room already exists, if so update it, otherwise create new + const roomId = `room:${sessionId}`; + const existingRoomRaw = await client.hget(this.ROOMS_HASH_KEY, roomId); + + let roomData: RoomRedisData; + if (existingRoomRaw) { + // Room exists - update expertId and preserve existing data + try { + roomData = JSON.parse(existingRoomRaw) as RoomRedisData; + const oldExpertId = roomData.expertId; + roomData.expertId = chosen.expertId; + roomData.isActive = true; + // Preserve existing messages, createdAt, etc. + this.logger.log(`[assignExpert] Updating existing room ${roomId}: expertId changed from ${oldExpertId} to ${chosen.expertId}`); + } catch (err) { + // Corrupted room data - create new room + this.logger.warn(`[assignExpert] Corrupted room data for ${roomId}, creating new room`, err as any); + roomData = { + roomId, + sessionId, + userId, + expertId: chosen.expertId, + createdAt: [ + new Date().toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date()), + ], + messages: [], + isActive: true, + }; + } + } else { + // Create new room + roomData = { + roomId, + sessionId, + userId, + expertId: chosen.expertId, + createdAt: [ + new Date().toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date()), + ], + messages: [], + isActive: true, + }; + } + await client.hset(this.ROOMS_HASH_KEY, roomId, JSON.stringify(roomData)); + + // Remove user from waiting queue after successful assignment + await this.removeUserFromQueue(userId); + + // === start of pending expert protection === + const pendingSeconds = Number(process.env.PENDING_EXPERT_SECONDS || '300'); + const PENDING_EXPERT_MS = pendingSeconds * 1000; + await client.set( + `session:${sessionId}:pendingExpertUntil`, + (Date.now() + PENDING_EXPERT_MS).toString(), + 'EX', + 60 * 60 // keep key for an hour + ); + await client.set(`session:${sessionId}:expertSeen`, 'false', 'EX', 60 * 60 * 24); + // === end of pending expert protection === + + // update Mongo (also store expert email in expert field) + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + connectedToExpert: true, + roomId, + onlineStartDate: new Date(), + onlineChatClosed: false, + expert: (await this.adminModel.findById(chosen.expertId).select('email').lean())?.email || String(chosen.expertId), + }, + }, + ); + + this.logger.log( + `assignExpert: user ${userId} assigned to expert ${chosen.expertId} (session ${sessionId})`, + ); + // Initialize inactivity tracker so scheduler can auto-close if no activity happens + await client.set( + `session:${sessionId}:lastActivity`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + + return { + status: 'assigned', + sessionId, + roomId, + expertId: chosen.expertId, + socketId: chosen.socketId, + }; + } catch (err) { + this.logger.error('assignExpert error', err as any); + throw err; + } + } + + async getWaitingPosition(userId: string): Promise { + try { + const client = this.client(); + const queue = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + // Parse each entry to extract the userId and find its index + const parsedQueue = queue.map(entry => { + try { + return JSON.parse(entry).userId; + } catch (e) { + return entry; // Fallback for old entries that might just be userId strings + } + }); + const idx = parsedQueue.indexOf(userId); + return idx >= 0 ? idx + 1 : 0; + } catch (err) { + this.logger.error('getWaitingPosition error', err as any); + return 0; + } + } + + async getWaitingUsers() { + const client = this.client(); + + try { + // 1️⃣ Fetch raw queued data from the WAITING_QUEUE_KEY (Redis list) + const rawQueue = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + + if (!rawQueue.length) { + return []; + } + + // 2️⃣ Parse queued data and fetch user details for each userId + const waitingUsers = await Promise.all( + rawQueue.map(async (entry) => { + let userId: string; + let queuedAt: string | null = null; + + try { + const parsedEntry = JSON.parse(entry); + userId = parsedEntry.userId; + queuedAt = parsedEntry.queuedAt; + } catch (e) { + // If parsing fails, assume it's a plain userId string + userId = entry; + queuedAt = null; // No queuedAt available for old entries + } + + const user = await this.userModel + .findById(userId) + .select('_id name family mobile username') // Select specific fields, changed afamily to family + .lean(); + + if (user) { + return { + _id: user._id.toString(), + name: user.name, + family: user.family, + mobile: user.mobile, + username: user.username, + queuedAt, + }; + } + return null; + }), + ); + + return waitingUsers.filter(Boolean); // Filter out any nulls if user not found + } catch (err) { + this.logger.error('getWaitingUsers error', err as any); + return []; + } + } + + async assignFromQueue(): Promise< + Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + }> + > { + try { + const client = this.client(); + let nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + const assignments: Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + fromExpertId?: string; + }> = []; + while (nextUser) { + const parsed = JSON.parse(nextUser) as WaitingQueueEntry; + const { userId, sessionId } = parsed; + + // Use the stored sessionId from the queue instead of finding any session + if (!sessionId) { + this.logger.warn(`assignFromQueue: missing sessionId for userId ${userId}, skipping`); + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // Check if user is still connected before assigning + const userSocketId = this.getConnectedSocketId(userId); + if (!userSocketId) { + this.logger.warn(`assignFromQueue: user ${userId} is not connected (disconnected), skipping session ${sessionId}`); + // Remove from queue since user is not connected + await this.removeUserFromQueue(userId); + // Try to close the session if it's not yet connected to expert + try { + const session = await this.sessionModel.findById(sessionId).select('connectedToExpert onlineChatClosed').lean(); + if (session && !session.connectedToExpert && !session.onlineChatClosed) { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + this.logger.log(`assignFromQueue: Auto-closed session ${sessionId} for disconnected user ${userId}`); + } + } catch (err) { + this.logger.warn(`assignFromQueue: Failed to close session ${sessionId} for disconnected user`, err as any); + } + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // Validate that the session exists and matches the user + const sessionValidation = await this.validateSessionId(sessionId, userId); + if (!sessionValidation.valid) { + this.logger.warn(`assignFromQueue: invalid sessionId ${sessionId} for userId ${userId}, skipping`); + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // Check if session is already connected to an expert + const session = await this.sessionModel.findById(sessionId).lean(); + if (!session || session.connectedToExpert) { + this.logger.warn(`assignFromQueue: session ${sessionId} not found or already connected, skipping`); + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // assignFromQueue is called when experts come online or chats end, + // which typically happens during business hours, so pass true + const assigned = await this.assignExpert( + { + userId, + sessionId, + }, + true, + parsed.preferredExpertId + ? { preferredExpertId: parsed.preferredExpertId } + : undefined, + ); + + if (assigned.status !== 'assigned') { + // couldn't assign (no capacity or no experts available) — push back user and stop + await client.lpush(this.WAITING_QUEUE_KEY, nextUser); + break; + } + + // record assignment for caller to notify sockets + assignments.push({ + userId: parsed.userId, + sessionId: assigned.sessionId, + roomId: assigned.roomId, + expertId: assigned.expertId, + socketId: assigned.socketId, + isTransfer: parsed.source === 'transfer', + }); + + // Remove user from queue after successful assignment (also removes any duplicates) + await this.removeUserFromQueue(parsed.userId); + + // assigned successfully — continue to next user in queue + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + } + return assignments; + } catch (err) { + this.logger.error('assignFromQueue error', err as any); + return []; + } + } + + // ============================= + // ===== MESSAGE HANDLING ====== + // ============================= + + /** + * Ensures the given user or expert may upload a voice file for this session (HTTP upload before sendMessage). + */ + async assertParticipantCanUploadVoice( + sessionId: string, + participantId: string, + senderRole: 'User' | 'Expert', + ): Promise { + if (!Types.ObjectId.isValid(sessionId)) { + throw new BadRequestException('Invalid sessionId'); + } + const doc = await this.sessionModel + .findById(sessionId) + .lean() + .select('chatClosed onlineChatClosed userId expert'); + if (!doc) { + throw new BadRequestException(`Session not found: ${sessionId}`); + } + if ((doc as any).chatClosed === true || (doc as any).onlineChatClosed === true) { + throw new BadRequestException('Chat session is closed'); + } + if (senderRole === 'User') { + const uid = (doc as any).userId?.toString?.(); + if (!uid || uid !== String(participantId)) { + throw new ForbiddenException('Not allowed to upload voice for this session'); + } + return; + } + const expertId = await this.normalizeExpertId((doc as any).expert, sessionId); + if (!expertId || expertId !== String(participantId)) { + throw new ForbiddenException('Expert is not assigned to this session'); + } + } + + private isVoiceMessagePathForSession(message: string, sessionId: string): boolean { + if (!message || typeof message !== 'string') return false; + const sid = sessionId.replace(/[^a-fA-F0-9]/g, ''); + return message.includes(`/uploads/voices/${sid}/`) || message.includes(`/voices/${sid}/`); + } + + private isObjectStorageChatKey( + message: string, + sessionId: string, + fileType: 'voice' | 'image' | 'document', + ): boolean { + if (!message || typeof message !== 'string') return false; + const parts = message.split('/').filter(Boolean); + if (parts.length !== 5) return false; + if (parts[0] !== 'chats') return false; + if (parts[1] !== sessionId) return false; + if (!Types.ObjectId.isValid(parts[2])) return false; + if (parts[3] !== fileType) return false; + return true; + } + + private isMalformedChatsPrefix(messageText: string, sessionId: string): boolean { + const t = (messageText ?? '').trim(); + if (!t.startsWith('chats/')) return false; + return ( + !this.isObjectStorageChatKey(t, sessionId, 'voice') && + !this.isObjectStorageChatKey(t, sessionId, 'image') && + !this.isObjectStorageChatKey(t, sessionId, 'document') + ); + } + + private extractMessageIdFromChatKey(messageText: string): string | null { + const parts = messageText.split('/').filter(Boolean); + if (parts.length < 3 || parts[0] !== 'chats') return null; + return parts[2] || null; + } + + private async assertChatAttachmentMatches(params: { + sessionId: string; + messageId: string; + senderId: string; + senderRole: UserRole; + storageKey: string; + fileType: 'voice' | 'image' | 'document'; + }): Promise { + const doc = await this.chatAttachmentModel + .findOne({ + sessionId: new Types.ObjectId(params.sessionId), + messageId: new Types.ObjectId(params.messageId), + }) + .lean() + .exec(); + if (!doc) { + throw new Error('attachment_not_found_for_message'); + } + if (doc.storageKey !== params.storageKey) { + throw new Error('attachment_storage_key_mismatch'); + } + if (doc.fileType !== params.fileType) { + throw new Error('attachment_type_mismatch'); + } + if (String(doc.uploaderId) !== String(params.senderId)) { + throw new ForbiddenException('attachment_uploader_mismatch'); + } + if (doc.uploaderRole !== params.senderRole) { + throw new ForbiddenException('attachment_role_mismatch'); + } + } + + async saveMessage(payload: SendMessagePayload) { + const { roomId, sessionId, senderId, senderRole, message: messageText, replyToMessageId } = payload; + const client = this.client(); + const roomKey = `room:${sessionId}`; + const createdISO = new Date().toISOString(); + + try { + // 0️⃣ Guard: prevent messaging into closed sessions + const statusDoc = await this.sessionModel + .findById(sessionId) + .lean() + .select('chatClosed onlineChatClosed userId expert createdAt messages'); + if (!statusDoc) throw new Error(`Session not found: ${sessionId}`); + if ((statusDoc as any).chatClosed === true || (statusDoc as any).onlineChatClosed === true) { + throw new Error('Chat session is closed'); + } + + const messageType = payload.type ?? 'text'; + let voiceDurationSec: number | undefined = + payload.voiceDurationSec != null ? Number(payload.voiceDurationSec) : undefined; + if (voiceDurationSec != null && Number.isNaN(voiceDurationSec)) { + voiceDurationSec = undefined; + } + const voiceMimeType = payload.mimeType?.trim() || undefined; + + const legacyVoice = + messageType === 'voice' && + this.isVoiceMessagePathForSession(messageText, sessionId); + const objVoice = + messageType === 'voice' && + this.isObjectStorageChatKey(messageText, sessionId, 'voice'); + const objImage = + messageType === 'image' && + this.isObjectStorageChatKey(messageText, sessionId, 'image'); + const objDocument = + messageType === 'document' && + this.isObjectStorageChatKey(messageText, sessionId, 'document'); + + const presetMessageId = payload.presetMessageId?.trim(); + + let messageId: string; + + if (legacyVoice) { + messageId = new Types.ObjectId().toString(); + } else if (objVoice || objImage || objDocument) { + const fileType: 'voice' | 'image' | 'document' = objVoice + ? 'voice' + : objImage + ? 'image' + : 'document'; + const idFromKey = this.extractMessageIdFromChatKey(messageText); + if (!idFromKey || !Types.ObjectId.isValid(idFromKey)) { + throw new Error('invalid_attachment_message_id_in_key'); + } + if (!presetMessageId || presetMessageId !== idFromKey) { + throw new Error( + 'presetMessageId_required_and_must_match_object_storage_key', + ); + } + await this.assertChatAttachmentMatches({ + sessionId, + messageId: idFromKey, + senderId, + senderRole, + storageKey: messageText, + fileType, + }); + messageId = idFromKey; + } else if (messageType === 'voice') { + const t = (messageText ?? '').trim(); + if (t.startsWith('chats/')) { + const parts = t.split('/').filter(Boolean); + if (parts.length >= 2 && parts[1] !== sessionId) { + throw new Error( + `voice_storage_key_session_mismatch: key contains session ${parts[1]} but sendMessage sessionId is ${sessionId} — they must be the same as the upload endpoint session.`, + ); + } + } + throw new Error( + 'Invalid voice message: use object-storage key from upload API or legacy /uploads/voices// path', + ); + } else if (this.isMalformedChatsPrefix(messageText, sessionId)) { + throw new Error('invalid_object_storage_key'); + } else { + messageId = new Types.ObjectId().toString(); + } + + // 1️⃣ Try to fetch room from Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + + // 0.5️⃣ Lightweight validation: Check if both parties are connected (in-memory check, very fast) + // This prevents sending messages to disconnected users/experts without adding significant load + if (roomRaw) { + try { + const room = JSON.parse(roomRaw) as RoomRedisData; + if (senderRole === 'Expert') { + // Expert sending message - check if user is connected + const userSocketId = this.getConnectedSocketId(room.userId); + if (!userSocketId) { + this.logger.warn(`saveMessage: User ${room.userId} is not connected, but allowing message (expert may reconnect user)`); + // Don't throw error - allow message to be saved, but log warning + } + } else if (senderRole === 'User') { + // User sending message - check if expert is connected + if (room.expertId) { + const expertSocketId = this.expertSockets.get(room.expertId); + if (!expertSocketId) { + this.logger.warn(`saveMessage: Expert ${room.expertId} is not connected, but allowing message (user may reconnect expert)`); + // Don't throw error - allow message to be saved, but log warning + } + } + } + } catch (err) { + // If validation fails, continue anyway (non-critical check) + this.logger.debug('saveMessage: Lightweight connection check failed, continuing', err as any); + } + } + let room: RoomRedisData | null = roomRaw + ? (JSON.parse(roomRaw) as RoomRedisData) + : null; + + // 2️⃣ If Redis room missing → bootstrap from Mongo + if (!room) { + const session = statusDoc; // already loaded above + + if (!session) throw new Error(`Session not found: ${sessionId}`); + + // Validate userId before creating room + const validatedUserId = await this.validateAndGetUserId(session, sessionId); + if (!validatedUserId) { + throw new Error(`Cannot create room: session ${sessionId} has invalid userId`); + } + + // Use session creation time, not current time + const createdAtValue = + session?.createdAt instanceof Date + ? session.createdAt.toISOString() + : session?.createdISO instanceof Date + ? session.createdISO.toISOString() + : new Date().toISOString(); + + const mongoMessages = + session?.messages?.map((m: any) => ({ + messageId: m.messageId?.toString?.() ?? new Types.ObjectId().toString(), + message: m.text ?? m.message, + sender: m.sender, + react: m.react ?? 'Nothing', + createdISO: m.createdISO || new Date().toISOString(), + createdAt: [ + new Date(m.createdISO).toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit', + }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format( + new Date(m.createdISO), + ), + ] as [string, string], + edited: !!m.edited, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + mimeType: m.mimeType, + })) || []; + + // Normalize expertId from session.expert (which may be username/email or ObjectId string) + const expertIdFromSession = await this.normalizeExpertId( + (session as any)?.expert, + sessionId + ); + + room = { + roomId: roomKey, + sessionId, + userId: validatedUserId, + expertId: expertIdFromSession, + createdAt: [ + new Date(createdAtValue).toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit', + }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(createdAtValue)), + ], + isActive: true, + messages: mongoMessages, + }; + + this.logger.log( + `Created Redis room from Mongo data for session ${sessionId}`, + ); + } + + // 3️⃣ Create new message (optional reply / quote to another message in this session) + let replyTo: MessageReplyToSnapshot | undefined; + if (replyToMessageId) { + if (!Types.ObjectId.isValid(replyToMessageId)) { + throw new Error('Invalid replyToMessageId'); + } + replyTo = this.buildReplyToSnapshot(replyToMessageId, room, statusDoc); + } + + const resolvedMessageType = + messageType === 'voice' + ? 'voice' + : messageType === 'image' + ? 'image' + : messageType === 'document' + ? 'document' + : 'text'; + + const newMessage: MessageRedis = { + messageId, + message: messageText, + sender: senderRole, + react: 'Nothing', + createdISO, + createdAt: [ + new Date(createdISO).toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit', + }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(createdISO)), + ], + edited: false, + messageType: resolvedMessageType, + ...(messageType === 'voice' && + voiceDurationSec != null && + !Number.isNaN(voiceDurationSec) + ? { voiceDurationSec } + : {}), + ...(messageType === 'voice' && voiceMimeType ? { voiceMimeType } : {}), + ...((messageType === 'image' || messageType === 'document') && voiceMimeType + ? { mimeType: voiceMimeType } + : {}), + ...(replyTo ? { replyTo } : {}), + }; + + // 4️⃣ Append message to Redis room (in-memory) + room.messages.push(newMessage); + + // 5️⃣ Persist back to Redis + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + // 5.5️⃣ Ensure expert/session mapping exists for onlineExperts and canonical SETs + if (room.expertId && Types.ObjectId.isValid(String(room.expertId))) { + await this.ensureExpertSessionMapping(String(room.expertId), sessionId); + } + + // Note: The "only one expert skipped" marker is cleared in refreshInactiveChatTimer() + // when called with isExpertMessage=true from the gateway after saveMessage + + // 6️⃣ Persist message to MongoDB session document + const pushDoc: Record = { + messageId: new Types.ObjectId(messageId), + text: messageText, + sender: senderRole, + createdISO: new Date(createdISO), + react: 'Nothing', + edited: false, + messageType: resolvedMessageType, + }; + if (messageType === 'voice') { + if (voiceDurationSec != null && !Number.isNaN(voiceDurationSec)) { + (pushDoc as any).voiceDurationSec = voiceDurationSec; + } + if (voiceMimeType) { + (pushDoc as any).voiceMimeType = voiceMimeType; + } + } + if ( + (messageType === 'image' || messageType === 'document') && + voiceMimeType + ) { + (pushDoc as any).mimeType = voiceMimeType; + } + if (replyTo) { + (pushDoc as any).replyTo = { + messageId: new Types.ObjectId(replyTo.messageId), + textPreview: replyTo.textPreview, + sender: replyTo.sender, + createdISO: new Date(this.normalizeCreatedISO(replyTo.createdISO)), + unavailable: false, + }; + } + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $push: { messages: pushDoc as any }, + }, + ); + + this.logger.log( + `Message saved successfully for session ${sessionId} (${senderRole})`, + ); + + // 7️⃣ Return unified message object for response + return { + roomId, + sessionId, + senderId, + senderRole, + messageId, + message: messageText, + createdISO, + type: resolvedMessageType, + ...(messageType === 'voice' && + voiceDurationSec != null && + !Number.isNaN(voiceDurationSec) + ? { voiceDurationSec } + : {}), + ...(messageType === 'voice' && voiceMimeType ? { mimeType: voiceMimeType } : {}), + ...((messageType === 'image' || messageType === 'document') && voiceMimeType + ? { mimeType: voiceMimeType } + : {}), + ...(replyTo ? { replyTo } : {}), + }; + } catch (err) { + this.logger.error('saveMessage error:', err); + throw new Error(`Failed to save message: ${err.message}`); + } + } + + async editMessage(payload: { + roomId: string; + sessionId: string; + messageId: string; + newText: string; + editorId: string; + editorRole: 'expert'; + }) { + const { roomId, sessionId, messageId, newText } = payload; + const client = this.client(); + this.logger.log(`editMessage: sessionId=${sessionId} roomId=${roomId} messageId=${messageId}`); + + // 1️⃣ Update Redis copy (if exists) — use canonical key room:${sessionId}, with fallback to provided roomId + const canonicalKey = `room:${sessionId}`; + let redisKeyUsed = canonicalKey; + let roomRaw = await client.hget(this.ROOMS_HASH_KEY, canonicalKey); + if (!roomRaw && roomId && roomId !== canonicalKey) { + const fallbackRaw = await client.hget(this.ROOMS_HASH_KEY, roomId); + if (fallbackRaw) { + roomRaw = fallbackRaw; + redisKeyUsed = roomId; + } + } + let updatedMessage = null; + + if (roomRaw) { + const room = JSON.parse(roomRaw); + const msgIndex = room.messages.findIndex((m: any) => m.messageId === messageId); + if (msgIndex !== -1) { + room.messages[msgIndex].message = newText; + room.messages[msgIndex].edited = true; + room.messages[msgIndex].editedAt = Date.now(); + + updatedMessage = room.messages[msgIndex]; + await client.hset(this.ROOMS_HASH_KEY, redisKeyUsed, JSON.stringify(room)); + } else { + this.logger.warn(`editMessage: messageId ${messageId} not found in Redis room ${redisKeyUsed}`); + } + } + + // 2️⃣ Update MongoDB version (robust to ObjectId vs string id) + const messageIdObj = Types.ObjectId.isValid(messageId) ? new Types.ObjectId(messageId) : null; + const filterPrimary: any = messageIdObj + ? { _id: sessionId, 'messages.messageId': messageIdObj } + : { _id: sessionId, 'messages.messageId': messageId }; + + const updateDoc = { + $set: { + 'messages.$.text': newText, + 'messages.$.edited': true, + 'messages.$.editedAt': new Date(), + }, + } as const; + + const res = await this.sessionModel.updateOne(filterPrimary, updateDoc); + if (!res?.modifiedCount) { + // Fallback: if we located the message index in Redis, update by index to bypass id mismatch + if (roomRaw) { + try { + const room = JSON.parse(roomRaw); + const idx = room.messages.findIndex((m: any) => m.messageId === messageId); + if (idx !== -1) { + const indexUpdate: any = { + $set: { + [`messages.${idx}.text`]: newText, + [`messages.${idx}.edited`]: true, + [`messages.${idx}.editedAt`]: new Date(), + }, + }; + await this.sessionModel.updateOne({ _id: sessionId }, indexUpdate); + } + } catch {} + } + } + + // 3️⃣ Return updated message + return ( + updatedMessage ?? { + messageId, + message: newText, + edited: true, + editedAt: Date.now(), + } + ); + } + + + async getChatHistory(sessionId: string) { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + try { + // 1️⃣ Try Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + let redisRoom: RoomRedisData | null = null; + if (roomRaw) { + redisRoom = JSON.parse(roomRaw) as RoomRedisData; + } + + // 2️⃣ Mongo session messages (AI/user history) + const session = await this.sessionModel + .findById(sessionId) + .lean() + .select("messages userId expert createdAt createdISO onlineStartDate"); + + const mongoMessages = + session?.messages?.map((m: any) => ({ + messageId: m.messageId?.toString?.() ?? new Types.ObjectId().toString(), + message: m.text ?? m.message, + sender: m.sender, + react: m.react ?? "Nothing", + createdISO: m.createdISO || new Date().toISOString(), + createdAt: [ + new Date(m.createdISO).toLocaleTimeString("fa-IR", { + hour: "2-digit", + minute: "2-digit", + }), + new Intl.DateTimeFormat("fa-IR-u-ca-persian").format( + new Date(m.createdISO), + ), + ] as [string, string], + edited: !!m.edited, + seenBy: {}, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })) || []; + + // 3️⃣ Redis messages (if any) + const redisMessages = + redisRoom?.messages?.map((m) => ({ + messageId: m.messageId, + message: m.message, + sender: m.sender, + createdISO: m.createdISO, + createdAt: m.createdAt, // already [string, string] + react: m.react ?? "Nothing", + edited: !!m.edited, + seenBy: (m as any).seenBy ?? {}, + replyTo: m.replyTo ? { ...m.replyTo } : undefined, + messageType: (m as MessageRedis).messageType || 'text', + voiceDurationSec: (m as MessageRedis).voiceDurationSec, + voiceMimeType: (m as MessageRedis).voiceMimeType, + })) || []; + + // 4️⃣ Merge with deduplication (prefer Redis version when both exist) + const mergedById = new Map(); + for (const m of mongoMessages) mergedById.set(m.messageId, m); + for (const m of redisMessages) mergedById.set(m.messageId, m); + const allMessages = Array.from(mergedById.values()).sort( + (a, b) => + new Date(a.createdISO).getTime() - new Date(b.createdISO).getTime(), + ); + const withReplyFlags = this.enrichReplyToAvailability( + allMessages as { messageId: string; replyTo?: MessageReplyToSnapshot }[], + ) as typeof allMessages; + + // 5️⃣ If Redis room not found, initialize it with Mongo messages + if (!redisRoom) { + // Validate userId before creating room + const validatedUserId = await this.validateAndGetUserId(session, sessionId); + if (!validatedUserId) { + this.logger.warn(`Cannot initialize room for session ${sessionId}: invalid userId`); + // Prepare response with createdAt and onlineStartDate + const createdAt = session?.createdAt || null; + let onlineStartDate: [string, string] | null = null; + + if (session?.onlineStartDate) { + onlineStartDate = TimeHelper.iso2PersianTimeAndDate(session.onlineStartDate); + } + + return { + history: withReplyFlags, + createdAt, + onlineStartDate, + }; + } + + // Normalize expertId + const normalizedExpertId = await this.normalizeExpertId(session?.expert, sessionId); + + // Use session creation time, not current time + const createdAtValue = + session?.createdAt instanceof Date + ? session.createdAt.toISOString() + : session?.createdISO instanceof Date + ? session.createdISO.toISOString() + : new Date().toISOString(); + + const newRoom: RoomRedisData = { + roomId: roomKey, + sessionId, + userId: validatedUserId, + expertId: normalizedExpertId, + createdAt: [ + new Date(createdAtValue).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(createdAtValue)), + ], + isActive: true, + messages: mongoMessages, + }; + + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(newRoom)); + + this.logger.log( + `Initialized Redis room with Mongo history for session ${sessionId}`, + ); + } + + // 6️⃣ Prepare response with createdAt and onlineStartDate + const createdAt = session?.createdAt || null; + let onlineStartDate: [string, string] | null = null; + + if (session?.onlineStartDate) { + onlineStartDate = TimeHelper.iso2PersianTimeAndDate(session.onlineStartDate); + } + + return { + history: withReplyFlags, + createdAt, + onlineStartDate, + }; + } catch (err) { + this.logger.error("getChatHistory error", err as any); + return { + history: [], + createdAt: null, + onlineStartDate: null, + }; + } + } + + + // ============================= + // ===== END CHAT / CLEANUP ==== + // ============================= + + async endChat(sessionId: string): Promise< + Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + }> + > { + try { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + // Check if session is already closed before proceeding + const session = await this.sessionModel.findById(sessionId).select('onlineChatClosed chatClosed expert').lean(); + const wasAlreadyClosed = session && (session.onlineChatClosed || session.chatClosed); + + if (wasAlreadyClosed) { + this.logger.warn( + `endChat: session ${sessionId} is already closed (onlineChatClosed: ${session.onlineChatClosed}, chatClosed: ${session.chatClosed}). Cleaning Redis only.`, + ); + } + + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + + if (!roomRaw) { + // Room not in Redis but session exists + if (!wasAlreadyClosed) { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + this.logger.warn( + `endChat: room ${roomKey} not found in Redis; updated session flags in Mongo`, + ); + } + + // CRITICAL: Even if room is missing, we must clean up any stale entries in expert SETs + // This handles cases where session was closed in Mongo but Redis SET wasn't cleaned + if (session?.expert) { + try { + const expertId = await this.normalizeExpertId(session.expert, sessionId); + if (expertId) { + await this.removeSessionFromExpertSet(expertId, sessionId); + this.logger.log(`endChat: Cleaned stale session ${sessionId} from expert ${expertId} SET (room was missing)`); + + // Sync expert hash if online + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + if (expertRaw) { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } + } + } catch (err) { + this.logger.warn(`endChat: Failed to clean stale session from expert SET`, err as any); + } + } + + // Even if room object is missing, try to assign from queue + const assignments = await this.assignFromQueue(); + return assignments; + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + + // Persist only messages that are not already stored in Mongo (by messageId) + const existing = await this.sessionModel + .findById(sessionId) + .select('messages.messageId') + .lean(); + const existingIds = new Set( + (existing?.messages || []).map((x: any) => (x.messageId?.toString?.() ?? String(x.messageId))) + ); + + const messagesToPersist = room.messages + .filter((m: any) => !existingIds.has(String(m.messageId))) + .map((m: any) => { + const base: any = { + // Keep the same shape as saveMessage to avoid type drift + messageId: String(m.messageId), + text: m.message, + sender: m.sender, + createdISO: String(m.createdISO), + react: m.react ?? 'Nothing', + edited: !!m.edited, + }; + if (m.replyTo?.messageId) { + base.replyTo = { + messageId: new Types.ObjectId(m.replyTo.messageId), + textPreview: m.replyTo.textPreview ?? '', + sender: m.replyTo.sender, + createdISO: new Date(this.normalizeCreatedISO(m.replyTo.createdISO)), + unavailable: !!m.replyTo.unavailable, + }; + } + base.messageType = m.messageType ?? 'text'; + if (m.voiceDurationSec != null && !Number.isNaN(Number(m.voiceDurationSec))) { + base.voiceDurationSec = Number(m.voiceDurationSec); + } + if (m.voiceMimeType) { + base.voiceMimeType = m.voiceMimeType; + } + return base; + }); + + // Only update Mongo if not already closed + if (!wasAlreadyClosed) { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + ...(messagesToPersist.length + ? { $push: { messages: { $each: messagesToPersist } } } + : {}), + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + } else if (messagesToPersist.length > 0) { + // If already closed but has new messages, persist them + await this.sessionModel.updateOne( + { _id: sessionId }, + { $push: { messages: { $each: messagesToPersist } } }, + ); + } + + // --- Expert cleanup --- + // Always remove the session from the canonical Redis SET even if the expert is offline + const remainingCount = await this.removeSessionFromExpertSet(room.expertId, sessionId); + + // If expert is currently online, also sync the hash entry from the SET + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, room.expertId); + if (expertRaw) { + try { + await this.updateOnlineExpertHash(room.expertId, { + includeSessionIdsFromSet: true, + activeSessions: remainingCount, + }); + } catch (err) { + this.logger.warn('endChat: failed to update onlineExperts hash for expert', err as any); + } + } else { + this.logger.warn(`endChat: expert ${room.expertId} not present in online experts (hash not synced)`); + } + + // remove room + await client.hdel(this.ROOMS_HASH_KEY, roomKey); + + // cleanup auxiliary per-session keys + try { + await Promise.all([ + client.del(`session:${sessionId}:expertSeen`), + client.del(`session:${sessionId}:pendingExpertUntil`), + client.del(`session:${sessionId}:pendingExpertFor`), + client.del(`${this.INACTIVE_PREFIX}${sessionId}`), + client.del(`${this.PENDING_PREFIX}${sessionId}`), + client.del(`session:${sessionId}:lastActivity`), + ]); + } catch {} + + this.logger.log(`endChat: session ${sessionId} persisted and cleaned from Redis (room and aux keys)`); + + // assign next waiting user + const assignments = await this.assignFromQueue(); + return assignments; + } catch (err) { + this.logger.error('endChat error', err as any); + throw err; + } + } + + /** + * Finds and force closes all active sessions for a user + * This is used when a user requests a new expert while having an active session + * @param userId - The user ID to find active sessions for + * @returns Array of closed session IDs + */ + async forceCloseUserActiveSessions(userId: string): Promise { + const closedSessionIds: string[] = []; + + try { + // 1. Check Redis for active sessions (findAll to catch multiple sessions) + const redisActiveSessions = await this.getUserActiveSessions(userId); + + if (redisActiveSessions.length > 0) { + this.logger.log(`[forceCloseUserActiveSessions] Found ${redisActiveSessions.length} active Redis sessions for user ${userId}, closing them`); + for (const session of redisActiveSessions) { + try { + await this.endChat(session.sessionId); + closedSessionIds.push(session.sessionId); + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Failed to close Redis session ${session.sessionId}`, err as any); + } + } + } + + // 2. Check MongoDB for any active sessions that might not be in Redis + const activeMongoSessions = await this.sessionModel.find({ + userId: new Types.ObjectId(userId), + connectedToExpert: true, + onlineChatClosed: false, + }).select('_id roomId').lean(); + + for (const session of activeMongoSessions) { + const sessionId = String(session._id); + + // Skip if we already closed this session from Redis check + if (closedSessionIds.includes(sessionId)) { + continue; + } + + this.logger.log(`[forceCloseUserActiveSessions] Found active MongoDB session ${sessionId} for user ${userId}, closing it`); + try { + await this.endChat(sessionId); + closedSessionIds.push(sessionId); + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Failed to close MongoDB session ${sessionId}`, err as any); + } + } + + // 3. Also check if user is in any expert's session SET (canonical source) + // This handles edge cases where session might be in expert SET but not in rooms + const client = this.client(); + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + + if (expertsRaw && Object.keys(expertsRaw).length > 0) { + for (const expertId of Object.keys(expertsRaw)) { + try { + // Use canonical Redis SET instead of hash array + const setKey = this.expertSessionsKey(expertId); + const sessionIds = await client.smembers(setKey); + + // Check each session to see if it belongs to this user + for (const sessionId of sessionIds) { + // Skip if already closed + if (closedSessionIds.includes(sessionId)) { + continue; + } + + try { + const session = await this.sessionModel.findById(sessionId).select('userId connectedToExpert onlineChatClosed').lean(); + if (session && String(session.userId) === userId) { + // Check if session is still active + if (session.connectedToExpert && !session.onlineChatClosed) { + // This session should have been closed but wasn't - close it now + this.logger.log(`[forceCloseUserActiveSessions] Found orphaned active session ${sessionId} in expert ${expertId} SET, closing it`); + try { + await this.endChat(sessionId); + closedSessionIds.push(sessionId); + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Failed to close orphaned session ${sessionId}`, err as any); + } + } + } + } catch (err) { + // Skip invalid session IDs + } + } + } catch (err) { + // Skip corrupted expert data + } + } + } + + if (closedSessionIds.length > 0) { + this.logger.log(`[forceCloseUserActiveSessions] Force closed ${closedSessionIds.length} active session(s) for user ${userId}: ${closedSessionIds.join(', ')}`); + } else { + this.logger.log(`[forceCloseUserActiveSessions] No active sessions found for user ${userId}`); + } + + return closedSessionIds; + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Error closing active sessions for user ${userId}`, err as any); + return closedSessionIds; // Return what we managed to close + } + } + + async handleDisconnectBySocket(socketId: string) { + try { + // remove from connectedClients + const pair = [...this.connectedClients.entries()].find(([, s]) => s === socketId); + if (pair) { + const userId = pair[0]; + this.connectedClients.delete(userId); + this.logger.log(`handleDisconnectBySocket: removed mapping ${userId} -> ${socketId}`); + } + // we intentionally do NOT change Redis expert online state here to avoid race conditions. + } catch (err) { + this.logger.error('handleDisconnectBySocket error', err as any); + } + } + + async getRoomDetails(roomKey: string) { + const client = this.client(); + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) return null; + const room = JSON.parse(roomRaw); + + // Optional — enrich user/expert info + const user = await this.userModel.findById(room.userId).select('name family mobile'); + const expert = await this.adminModel.findById(room.expertId).select('name family role'); + + // Ensure messages are present similar to getChatHistory; if empty in Redis, hydrate from Mongo + if (!Array.isArray(room.messages) || room.messages.length === 0) { + try { + const sessionId = String(room.sessionId); + const session = await this.sessionModel + .findById(sessionId) + .lean() + .select('messages userId expert createdAt'); + const mongoMessages = (session?.messages || []).map((m: any) => ({ + messageId: m.messageId?.toString?.() ?? new Types.ObjectId().toString(), + message: m.text ?? m.message, + sender: m.sender, + react: m.react ?? 'Nothing', + createdISO: m.createdISO || new Date().toISOString(), + createdAt: [ + new Date(m.createdISO).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(m.createdISO)), + ] as [string, string], + edited: !!m.edited, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })); + room.messages = mongoMessages; + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + } catch (err) { + this.logger.warn('getRoomDetails: failed to hydrate messages from Mongo', err as any); + } + } + + return { ...room, user, expert }; + } + + async getExpertActiveSessions(expertId: string) { + const client = this.client(); + + // 1) Read expert entry + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + if (!expertRaw) return { expertId, sessions: [] }; + + let expert: ExpertRedisData; + try { + expert = JSON.parse(expertRaw) as ExpertRedisData; + } catch (err) { + this.logger.warn('getExpertActiveSessions: failed parse expert raw', err); + return { expertId, sessions: [] }; + } + + // Normalize sessionIds (dedupe and filter) and prefer canonical Redis Set if present + try { + const setSessionIds = await this.getExpertSessionIdsFromSet(expertId); + if (setSessionIds.length) { + expert.sessionIds = [...new Set(setSessionIds)]; + } else { + expert.sessionIds = Array.isArray(expert.sessionIds) + ? [...new Set(expert.sessionIds.filter(Boolean))] + : []; + } + } catch { + expert.sessionIds = Array.isArray(expert.sessionIds) + ? [...new Set(expert.sessionIds.filter(Boolean))] + : []; + } + + // 2) For each sessionId build summary object + const sessions = await Promise.all( + expert.sessionIds.map(async (sessionId) => { + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + + // base structure + const sessionInfo: any = { + sessionId, + roomId: roomKey, + user: null, + totalMessages: 0, + unseenByExpert: 0, + }; + + // Always read Mongo for authoritative message count (per requirement) + const mongoSession = await this.sessionModel + .findById(sessionId) + .lean() + .select('userId messages onlineStartDate'); + + if (mongoSession) { + sessionInfo.totalMessages = (mongoSession.messages || []).length; + // prefer userId from Redis room if present; otherwise from Mongo + sessionInfo.user = sessionInfo.user ?? mongoSession.userId?.toString?.() ?? null; + } + + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + // prefer Redis room userId when available + sessionInfo.user = room.userId ?? sessionInfo.user; + // count unseen messages for expert — ONLY user-sent messages not seen by expert + sessionInfo.unseenByExpert = (room.messages || []).reduce((acc: number, m: any) => { + const seenBy = m.seenBy ?? {}; + const senderNormalized = String(m.sender).toLowerCase(); + const sentByUser = senderNormalized === 'user'; + if (sentByUser && !seenBy.expert) return acc + 1; + return acc; + }, 0); + // include full messages for UI + const roomMsgs = (room.messages || []).map((m: any) => ({ + messageId: m.messageId, + message: m.message, + sender: m.sender, + createdISO: m.createdISO, + seenBy: m.seenBy ?? {}, + edited: !!m.edited, + replyTo: m.replyTo ? { ...m.replyTo } : undefined, + messageType: m.messageType || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })); + sessionInfo.messages = this.enrichReplyToAvailability( + roomMsgs as { messageId: string; replyTo?: MessageReplyToSnapshot }[], + ) as any; + } else { + // Calculate unseen messages from MongoDB when Redis room doesn't exist + // Only count user messages sent during online chat period (after onlineStartDate) + if (mongoSession && mongoSession.onlineStartDate) { + const onlineStartDate = new Date(mongoSession.onlineStartDate); + sessionInfo.unseenByExpert = (mongoSession.messages || []).reduce((acc: number, m: any) => { + const senderNormalized = String(m.sender).toLowerCase(); + const sentByUser = senderNormalized === 'user'; + const messageDate = m.createdISO ? new Date(m.createdISO) : null; + const sentAfterOnlineStart = messageDate && messageDate >= onlineStartDate; + if (sentByUser && sentAfterOnlineStart) return acc + 1; + return acc; + }, 0); + } else { + // If no onlineStartDate, we can't distinguish online vs AI messages, so default to 0 + sessionInfo.unseenByExpert = 0; + } + // fallback — provide full messages from Mongo + if (mongoSession) { + const mongoForExpert = (mongoSession.messages || []).map((m: any) => ({ + messageId: String(m.messageId?.toString?.() ?? ''), + message: m.text, + sender: m.sender, + createdISO: m.createdISO, + seenBy: { user: true, expert: false }, + edited: !!m.edited, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })); + sessionInfo.messages = this.enrichReplyToAvailability( + mongoForExpert as { messageId: string; replyTo?: MessageReplyToSnapshot }[], + ) as any; + } + } + + // fetch user details for UI (optional) + if (sessionInfo.user) { + try { + const u = await this.userModel.findById(sessionInfo.user).select('_id name family mobile').lean(); + if (u) sessionInfo.user = { _id: u._id.toString(), name: u.name, family: u.family, mobile: u.mobile }; + } catch (err) { + /* ignore */ + } + } + + return sessionInfo; + }), + ); + + // 3) Compute aggregated unseen total + const unseenTotal = sessions.reduce((acc: number, s: any) => acc + (s.unseenByExpert || 0), 0); + + return { expertId, sessions, unseenTotal, expertMeta: { activeSessions: expert.activeSessions, maxSessions: expert.maxSessions } }; + } + + async markAllExpertSessionsSeen(expertId: string) { + const client = this.client(); + + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + if (!expertRaw) return { expertId, updated: 0 }; + + let expert: ExpertRedisData; + try { + expert = JSON.parse(expertRaw) as ExpertRedisData; + } catch { + return { expertId, updated: 0 }; + } + + const sessionIds = Array.isArray(expert.sessionIds) ? [...new Set(expert.sessionIds.filter(Boolean))] : []; + let totalUpdated = 0; + + for (const sessionId of sessionIds) { + const res = await this.markMessagesSeen(sessionId, 'expert'); + totalUpdated += res.updated || 0; + } + + return { expertId, updated: totalUpdated }; + } + + async checkAndReassignInactiveExperts() { + const client = this.client(); + + // scan keys + // keys pattern: session::pendingExpertUntil + const keys = await client.keys('session:*:pendingExpertUntil'); + const reassignments: Array<{ sessionId: string; oldExpertId: string | null; newExpertId: string | null; userId?: string; reason?: string }> = []; + + for (const k of keys) { + try { + const sessionId = k.split(':')[1]; + const pendingUntilRaw = await client.get(k); + const pendingUntil = pendingUntilRaw ? parseInt(pendingUntilRaw, 10) : 0; + if (!pendingUntil) { + // corrupt key — remove it + await client.del(k); + continue; + } + + if (Date.now() < pendingUntil) continue; // still waiting + + // Check if expert has marked seen already + const seenKey = `session:${sessionId}:expertSeen`; + const seen = await client.get(seenKey); + if (seen === 'true') { + // expert already saw — cancel pending marker + await client.del(k); + continue; + } + + // Find current assigned expert (from room) + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + const oldExpertId = roomRaw ? (JSON.parse(roomRaw) as RoomRedisData).expertId : null; + + // Try reassign to another available expert + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + const experts: ExpertRedisData[] = Object.values(expertsRaw) + .map((v) => { + try { return JSON.parse(v as string) as ExpertRedisData; } catch { return null as any; } + }) + .filter(Boolean); + + // Detect if there is actually any *other* online expert besides the current one + const otherOnlineExperts = experts.filter( + (e) => e.isOnline && e.expertId !== oldExpertId, + ); + + // Only reassign if there is at least one alternative expert who has capacity + const available = otherOnlineExperts.filter( + (e) => + (e.activeSessions || 0) < + (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + + if (available.length === 0) { + // No alternative expert with capacity. + // If there are *no* other online experts at all (only this expert is online), + // we keep the session assigned to the current expert and just drop the pending marker. + // We also set a marker to track this situation for later logging when session closes. + if (otherOnlineExperts.length === 0) { + this.logger.log( + `[checkAndReassignInactiveExperts] Only one online expert (${oldExpertId}) for session ${sessionId}. Skipping reassignment and keeping expert until inactivity auto-close.`, + ); + // Set a marker to track that this session was in "only one expert" state + // This will be checked in checkAndCloseInactiveChats to log reassignment + if (oldExpertId) { + await client.set( + `session:${sessionId}:onlyOneExpertSkipped`, + oldExpertId, + 'EX', + 86400, // 24 hours expiry + ); + } + await client.del(k); // clear pending marker so we don't repeatedly try + continue; + } + + // There are other experts online but none currently have capacity. + // Add session to waiting queue so it can be reassigned when capacity opens. + if (oldExpertId) { + try { + // Get session to find userId + const session = await this.sessionModel + .findById(sessionId) + .select('userId expert connectedToExpert') + .lean(); + if (session) { + const userId = String(session.userId); + + // Remove session from old expert's SET + try { + await this.removeSessionFromExpertSet(oldExpertId, sessionId); + // Sync old expert's hash + const expertRaw = await client.hget( + this.ONLINE_EXPERTS_KEY, + oldExpertId, + ); + if (expertRaw) { + await this.updateOnlineExpertHash(oldExpertId, { + includeSessionIdsFromSet: true, + isOnline: true, + }); + } + } catch (err) { + this.logger.warn( + `[checkAndReassignInactiveExperts] Failed to remove session from old expert SET`, + err as any, + ); + } + + // Update room to remove expertId but keep room active + if (roomRaw) { + try { + const room = JSON.parse(roomRaw) as RoomRedisData; + room.expertId = null; // No expert assigned, session will be queued + await client.hset( + this.ROOMS_HASH_KEY, + roomKey, + JSON.stringify(room), + ); + this.logger.log( + `[checkAndReassignInactiveExperts] Updated room ${roomKey}: removed expertId, adding to waiting queue`, + ); + } catch (err) { + this.logger.warn( + `[checkAndReassignInactiveExperts] Failed to update room ${roomKey}`, + err as any, + ); + } + } + + // Update MongoDB session to unset expert and set connectedToExpert = false + try { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $unset: { expert: '' }, + $set: { connectedToExpert: false }, + }, + ); + } catch (err) { + this.logger.warn( + `[checkAndReassignInactiveExperts] Failed to update MongoDB session`, + err as any, + ); + } + + // Add to waiting queue so it can be reassigned when capacity opens + await this.enqueueUser(userId, sessionId); + await this.addWaitingUser(userId, sessionId); + this.logger.log( + `[checkAndReassignInactiveExperts] No expert with capacity available for session ${sessionId}. Added to waiting queue. Session will be reassigned when capacity opens.`, + ); + } + } catch (err) { + this.logger.error( + `[checkAndReassignInactiveExperts] Failed to queue session ${sessionId}`, + err as any, + ); + } + } + // Remove pending marker since we're not reassigning + await client.del(k); + continue; + } + + // pick least busy + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + const newExpert = available[0]; + + // CRITICAL: First, find which experts actually have this session in their SETs + // Do this BEFORE adding to new expert to avoid confusion + const expertsWithSession: string[] = []; + for (const expert of experts) { + if (expert.expertId !== newExpert.expertId) { + try { + const setKey = this.expertSessionsKey(expert.expertId); + const isMember = await client.sismember(setKey, sessionId); + if (isMember) { + expertsWithSession.push(expert.expertId); + this.logger.log(`[checkAndReassignInactiveExperts] Found session ${sessionId} in expert ${expert.expertId} SET before reassignment`); + } + } catch (err) { + this.logger.warn(`Failed to check if session ${sessionId} is in expert ${expert.expertId} SET`, err as any); + } + } + } + + // Remove session from all experts that currently have it (except new expert) + const expertsToSync: string[] = []; + for (const expertId of expertsWithSession) { + try { + const setKey = this.expertSessionsKey(expertId); + await client.srem(setKey, sessionId); + expertsToSync.push(expertId); + this.logger.log(`[checkAndReassignInactiveExperts] Removed session ${sessionId} from expert ${expertId} SET before reassignment`); + } catch (err) { + this.logger.warn(`Failed to remove session ${sessionId} from expert ${expertId} SET`, err as any); + // Don't abort - continue with reassignment even if some removals fail + } + } + + // NOW add to new expert's SET (after removing from all others) + try { + const newSetKey = this.expertSessionsKey(newExpert.expertId); + // Check if it's already there (shouldn't be, but be safe) + const alreadyInNewExpert = await client.sismember(newSetKey, sessionId); + if (!alreadyInNewExpert) { + await client.sadd(newSetKey, sessionId); + this.logger.log(`[checkAndReassignInactiveExperts] Added session ${sessionId} to new expert ${newExpert.expertId} SET`); + } else { + this.logger.warn(`[checkAndReassignInactiveExperts] Session ${sessionId} already in new expert ${newExpert.expertId} SET - skipping add`); + } + } catch (err) { + this.logger.error(`Failed to add session ${sessionId} to new expert ${newExpert.expertId} SET - aborting reassignment`, err as any); + // If we can't add to new expert, try to restore to old experts + for (const expertId of expertsWithSession) { + try { + const setKey = this.expertSessionsKey(expertId); + await client.sadd(setKey, sessionId); + this.logger.warn(`[checkAndReassignInactiveExperts] Restored session ${sessionId} to expert ${expertId} SET after failed reassignment`); + } catch (restoreErr) { + this.logger.error(`Failed to restore session ${sessionId} to expert ${expertId} SET`, restoreErr as any); + } + } + continue; // Skip this reassignment + } + + // Sync onlineExperts hash from canonical sets for new expert + try { + await this.updateOnlineExpertHash(newExpert.expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn('Failed to sync new expert hash from SET', err as any); + } + + // update room expertId - preserve existing room data + if (roomRaw) { + try { + const room = JSON.parse(roomRaw) as RoomRedisData; + const oldExpertIdFromRoom = room.expertId; + room.expertId = newExpert.expertId; + room.isActive = true; // Ensure room is marked as active + // Preserve all existing room data (messages, createdAt, userId, etc.) + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + this.logger.log(`[checkAndReassignInactiveExperts] Updated room ${roomKey}: expertId changed from ${oldExpertIdFromRoom} to ${newExpert.expertId}`); + } catch (err) { + this.logger.warn(`[checkAndReassignInactiveExperts] Failed to update room ${roomKey}`, err as any); + } + } else { + this.logger.warn(`[checkAndReassignInactiveExperts] No room found for session ${sessionId} during reassignment`); + } + + // Update MongoDB session document with new expert (same as in assignExpert) + try { + const newExpertEmail = (await this.adminModel.findById(newExpert.expertId).select('email').lean())?.email || String(newExpert.expertId); + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + expert: newExpertEmail, + }, + }, + ); + this.logger.log(`[checkAndReassignInactiveExperts] Updated MongoDB session ${sessionId} with new expert: ${newExpertEmail}`); + } catch (err) { + this.logger.warn(`[checkAndReassignInactiveExperts] Failed to update MongoDB session ${sessionId} with new expert`, err as any); + } + + // Sync all experts that had the session removed (to update their sessionIds & counts) + for (const expertId of expertsToSync) { + try { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`Failed to sync expert ${expertId} hash from SET after session removal`, err as any); + } + } + + // remove pending marker + await client.del(k); + + // Log the reassignment + if (oldExpertId) { + try { + // Get session to find userId + const session = await this.sessionModel.findById(sessionId).select('userId').lean(); + if (session) { + const userId = String(session.userId); + // Get expert emails + const oldExpert = await this.adminModel.findById(oldExpertId).select('email').lean(); + const newExpertDoc = await this.adminModel.findById(newExpert.expertId).select('email').lean(); + const oldExpertEmail = oldExpert?.email || String(oldExpertId); + const newExpertEmail = newExpertDoc?.email || String(newExpert.expertId); + + // Log reassignment from old expert to new expert + await this.logReassignment(sessionId, userId, oldExpertEmail, newExpertEmail); + } + } catch (err) { + this.logger.error(`[checkAndReassignInactiveExperts] Failed to log reassignment for session ${sessionId}`, err as any); + } + } + + reassignments.push({ sessionId, oldExpertId, newExpertId: newExpert.expertId }); + } catch (err) { + this.logger.warn('checkAndReassignInactiveExperts loop error', err as any); + } + } + + if (reassignments.length) { + this.logger.log(`Reassigned ${reassignments.length} sessions due to inactive experts`); + } + return reassignments; + } + + async markMessagesSeen(sessionId: string, viewerRole: 'user' | 'expert') { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + // 1) load room from Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + // nothing to mark as seen if no redis room exists + return { sessionId, updated: 0 }; + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + let updatedCount = 0; + + // 2) iterate messages and mark seen for viewerRole where appropriate + for (let i = 0; i < (room.messages || []).length; i++) { + const msg = room.messages[i] as any; + + // initialize seenBy structure + if (!msg.seenBy || typeof msg.seenBy !== 'object') msg.seenBy = {}; + + // Only mark messages **not sent by the viewerRole** as seen. + const senderRoleNormalized = String(msg.sender).toLowerCase(); + const viewerIsExpert = viewerRole === 'expert'; + const msgSentByViewer = (viewerIsExpert && senderRoleNormalized === 'expert') || (!viewerIsExpert && senderRoleNormalized === 'user'); + + if (!msgSentByViewer && !msg.seenBy[viewerRole]) { + msg.seenBy[viewerRole] = true; + updatedCount++; + } + } + + // 3) persist updated room back to Redis + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + // 4) (defer Mongo persistence to endChat) — but we may optionally update metadata counters in Mongo here if you want + // Return summary + return { sessionId, updated: updatedCount }; + } + + /** + * Mark messages as seen that were created BEFORE a specific timestamp. + * Used when expert joins room - only mark old messages, not new ones sent after join. + */ + async markMessagesSeenBeforeTimestamp( + sessionId: string, + viewerRole: 'user' | 'expert', + beforeTimestamp: Date, + ) { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + // 1) load room from Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + // nothing to mark as seen if no redis room exists + return { sessionId, updated: 0 }; + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + let updatedCount = 0; + const beforeTime = beforeTimestamp.getTime(); + + // 2) iterate messages and mark seen ONLY for messages created BEFORE the timestamp + for (let i = 0; i < (room.messages || []).length; i++) { + const msg = room.messages[i] as any; + + // Skip messages created AFTER the timestamp + const msgTime = msg.createdISO ? new Date(msg.createdISO).getTime() : 0; + if (msgTime >= beforeTime) { + continue; // Don't mark new messages sent after expert joined + } + + // initialize seenBy structure + if (!msg.seenBy || typeof msg.seenBy !== 'object') msg.seenBy = {}; + + // Only mark messages **not sent by the viewerRole** as seen. + const senderRoleNormalized = String(msg.sender).toLowerCase(); + const viewerIsExpert = viewerRole === 'expert'; + const msgSentByViewer = (viewerIsExpert && senderRoleNormalized === 'expert') || (!viewerIsExpert && senderRoleNormalized === 'user'); + + if (!msgSentByViewer && !msg.seenBy[viewerRole]) { + msg.seenBy[viewerRole] = true; + updatedCount++; + } + } + + // 3) persist updated room back to Redis + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + // Return summary + return { sessionId, updated: updatedCount }; + } + + async autoMarkIfViewerOnline( + roomId: string, + viewerRole: 'user' | 'expert', + connectedRoomSockets: string[], + ) { + try { + if (connectedRoomSockets.length > 0) { + const sessionId = roomId.replace('room:', ''); + await this.markMessagesSeen(sessionId, viewerRole); + } + } catch (err) { + console.error('autoMarkIfViewerOnline error:', err); + } + } + + async checkAndCloseInactiveChats() { + const client = this.client(); + // We store lastActivity as timestamp string in session::lastActivity + const keys = await client.keys('session:*:lastActivity'); + const closed: Array<{ sessionId: string; userId: string | null }> = []; + + const envSeconds = Number(process.env.CHAT_INACTIVE_CLOSE_SECONDS || '300'); + const INACTIVE_CLOSE_MS = envSeconds * 1000; + + for (const k of keys) { + try { + const sessionId = k.split(':')[1]; + const last = await client.get(k); + const lastTs = last ? parseInt(last, 10) : 0; + if (!lastTs) continue; + if (Date.now() - lastTs > INACTIVE_CLOSE_MS) { + // Get userId from room before closing (room will be deleted in endChat) + let userId: string | null = null; + try { + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + userId = room.userId || null; + } + } catch (err) { + this.logger.warn('Failed to get userId from room before closing', sessionId, err as any); + } + + // Check if this session was in "only one expert" state before closing + // If so, log reassignment to null (expert didn't answer) + // BUT: Only log if expert hasn't sent any messages (expert actually didn't answer) + try { + const onlyOneExpertMarker = await client.get(`session:${sessionId}:onlyOneExpertSkipped`); + if (onlyOneExpertMarker && userId) { + // Check if room has expert messages before logging "didn't answer" + let hasExpertMessages = false; + try { + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + // Check if any messages are from expert + hasExpertMessages = (room.messages || []).some( + (msg) => msg.sender?.toLowerCase() === 'expert' || msg.sender === 'Expert', + ); + } + } catch (err) { + // If we can't check room, also check MongoDB session as fallback + try { + const session = await this.sessionModel.findById(sessionId).select('messages').lean(); + if (session && (session as any).messages) { + hasExpertMessages = (session as any).messages.some( + (msg: any) => msg.sender?.toLowerCase() === 'expert' || msg.sender === 'Expert', + ); + } + } catch (mongoErr) { + this.logger.warn(`[checkAndCloseInactiveChats] Failed to check messages from MongoDB for session ${sessionId}`, mongoErr as any); + } + } + + if (!hasExpertMessages) { + // Expert truly didn't answer - log reassignment to null + const oldExpertId = onlyOneExpertMarker; + const oldExpert = await this.adminModel.findById(oldExpertId).select('email').lean(); + const oldExpertEmail = oldExpert?.email || String(oldExpertId); + + await this.logReassignment(sessionId, userId, oldExpertEmail, null); + this.logger.log( + `[checkAndCloseInactiveChats] Logged reassignment to null for session ${sessionId} that was in "only one expert" state (expert ${oldExpertId} didn't answer)`, + ); + } else { + // Expert did send messages - don't log as "didn't answer" + this.logger.log( + `[checkAndCloseInactiveChats] Skipped logging reassignment to null for session ${sessionId} because expert sent messages (expert did answer)`, + ); + } + + // Delete the marker after processing (whether we logged or not) + await client.del(`session:${sessionId}:onlyOneExpertSkipped`); + } + } catch (err) { + this.logger.warn('Failed to check/process onlyOneExpertSkipped marker', sessionId, err as any); + } + + // close chat by calling your endChat logic (which persists and cleans up) + try { + await this.endChat(sessionId); + closed.push({ sessionId, userId }); + } catch (err) { + this.logger.warn('Failed auto-close session', sessionId, err as any); + } + } + } catch (err) { + this.logger.warn('checkAndCloseInactiveChats loop error', err as any); + } + } + + if (closed.length) { + this.logger.log(`Auto-closed ${closed.length} inactive sessions`); + } + return closed; + } + + async checkAndOfflineInactiveExperts() { + const client = this.client(); + try { + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (!entries || Object.keys(entries).length === 0) return [] as string[]; + + const thresholdMs = Number(process.env.EXPERT_INACTIVE_OFFLINE_SECONDS || '900') * 1000; + const now = Date.now(); + const offlined: string[] = []; + + for (const expertId of Object.keys(entries)) { + try { + const disconnectedAt = await client.get(`expert:${expertId}:disconnectedAt`); + if (disconnectedAt) { + continue; + } + + const activeIds = await this.filterActiveSessions( + await this.getExpertSessionIdsFromSet(expertId), + ); + if (activeIds.length > 0) { + continue; + } + + const last = await client.get(`expert:${expertId}:lastActive`); + const lastTs = last ? parseInt(last, 10) : 0; + if (!lastTs || now - lastTs > thresholdMs) { + await this.setExpertOffline(expertId); + offlined.push(expertId); + } + } catch (err) { + this.logger.warn('checkAndOfflineInactiveExperts per-expert error', err as any); + } + } + + if (offlined.length) { + this.logger.log(`Auto-offlined ${offlined.length} inactive experts`); + } + return offlined; + } catch (err) { + this.logger.error('checkAndOfflineInactiveExperts error', err as any); + return [] as string[]; + } + } + + async refreshExpertLastActive(expertId: string) { + try { + const r = this.client(); + await r.set(`expert:${expertId}:lastActive`, Date.now().toString(), 'EX', 60 * 60 * 24); + } catch (err) { + this.logger.warn('refreshExpertLastActive error', err as any); + } + } + + +async repairRedisConsistency({ cleanStale = false }: { cleanStale?: boolean } = {}) { + try { + const client = this.client(); + + // First, fix duplicate sessions across expert SETs + const duplicateFixes = await this.fixDuplicateSessionsAcrossExperts(); + + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + + // Health check: Detect and clean orphaned sessions (rooms where user/expert is disconnected) + const orphanedSessions: string[] = []; + if (roomsRaw && Object.keys(roomsRaw).length > 0) { + this.logger.log(`[repairRedisConsistency] Checking ${Object.keys(roomsRaw).length} rooms for orphaned sessions`); + for (const [roomKey, raw] of Object.entries(roomsRaw)) { + try { + const room = JSON.parse(raw) as RoomRedisData; + const sessionId = room.sessionId; + + // Check if user is connected + const userSocketId = this.getConnectedSocketId(room.userId); + const isUserConnected = !!userSocketId; + + // Check if expert is connected + let isExpertConnected = false; + if (room.expertId) { + const expertSocketId = this.expertSockets.get(room.expertId); + isExpertConnected = !!expertSocketId; + } + + // If neither party is connected, mark as orphaned + if (!isUserConnected && !isExpertConnected) { + // Verify session exists and is still active in MongoDB + try { + const session = await this.sessionModel.findById(sessionId).select('connectedToExpert onlineChatClosed').lean(); + if (session && session.connectedToExpert && !session.onlineChatClosed) { + orphanedSessions.push(sessionId); + this.logger.warn(`[repairRedisConsistency] Found orphaned session ${sessionId}: both user ${room.userId} and expert ${room.expertId || 'none'} are disconnected`); + } + } catch (err) { + // Session doesn't exist or error - skip + } + } + } catch (err) { + // Skip corrupted room data + this.logger.debug(`[repairRedisConsistency] Error checking room ${roomKey}`, err as any); + } + } + + // Clean up orphaned sessions + if (orphanedSessions.length > 0 && cleanStale) { + this.logger.warn(`[repairRedisConsistency] Cleaning up ${orphanedSessions.length} orphaned sessions`); + for (const sessionId of orphanedSessions) { + try { + // Close the session properly + await this.endChat(sessionId); + this.logger.log(`[repairRedisConsistency] Closed orphaned session ${sessionId}`); + } catch (err) { + this.logger.error(`[repairRedisConsistency] Failed to close orphaned session ${sessionId}`, err as any); + } + } + } else if (orphanedSessions.length > 0) { + this.logger.warn(`[repairRedisConsistency] Found ${orphanedSessions.length} orphaned sessions (use cleanStale=true to clean them)`); + } + } + + if (!expertsRaw || Object.keys(expertsRaw).length === 0) { + this.logger.warn('repairRedisConsistency: no experts found'); + const result: any = duplicateFixes.length > 0 ? { duplicateFixes } : undefined; + if (orphanedSessions.length > 0) { + return { ...(result || {}), orphanedSessions: orphanedSessions.length }; + } + return result; + } + + const repaired: { expertId: string; fixes: string[] }[] = []; + + // Build a quick lookup for existing room sessions + const existingRoomIds = new Set(Object.keys(roomsRaw || {})); + const existingSessionIds = new Set( + Array.from(existingRoomIds).map((k) => k.replace(/^room:/, '')), + ); + + for (const [expertId, raw] of Object.entries(expertsRaw)) { + let expert: ExpertRedisData; + const fixes: string[] = []; + + try { + expert = JSON.parse(raw); + } catch { + fixes.push('Corrupted JSON — entry removed'); + await client.hdel(this.ONLINE_EXPERTS_KEY, expertId); + repaired.push({ expertId, fixes }); + continue; + } + + // --- Normalize sessionIds from canonical set if exists --- + const setSessionIds = await this.getExpertSessionIdsFromSet(expertId); + if (setSessionIds.length) { + // CRITICAL: Filter out closed sessions before syncing + // This prevents stale/closed sessions from being added back to onlineExperts + const activeSessionIds = await this.filterActiveSessions(setSessionIds); + expert.sessionIds = [...new Set(activeSessionIds)]; + expert.activeSessions = expert.sessionIds.length; + + // If we filtered out closed sessions, remove them from the SET as well + if (activeSessionIds.length < setSessionIds.length) { + const closedSessionIds = setSessionIds.filter(id => !activeSessionIds.includes(id)); + for (const closedSessionId of closedSessionIds) { + try { + await this.removeSessionFromExpertSet(expertId, closedSessionId); + this.logger.log(`[repairRedisConsistency] Removed closed session ${closedSessionId} from expert ${expertId} SET`); + } catch (err) { + this.logger.warn(`[repairRedisConsistency] Failed to remove closed session ${closedSessionId} from SET`, err as any); + } + } + fixes.push(`Synced from SET, removed ${closedSessionIds.length} closed session(s)`); + } else { + fixes.push('Synced from SET'); + } + } else { + // Fallback to existing array with normalization + expert.sessionIds = Array.isArray(expert.sessionIds) + ? expert.sessionIds.filter(Boolean) + : []; + const before = expert.sessionIds.length; + expert.sessionIds = [...new Set(expert.sessionIds)]; + const after = expert.sessionIds.length; + if (before !== after) fixes.push(`Removed ${before - after} duplicate sessionIds`); + } + + // --- Optionally remove stale sessions --- + if (cleanStale) { + const beforeClean = expert.sessionIds.length; + expert.sessionIds = expert.sessionIds.filter((sid) => + existingSessionIds.has(sid), + ); + const afterClean = expert.sessionIds.length; + if (beforeClean !== afterClean) + fixes.push(`Removed ${beforeClean - afterClean} stale sessionIds`); + } + + // --- Recalculate activeSessions --- + const oldCount = expert.activeSessions; + expert.activeSessions = expert.sessionIds.length; + if (oldCount !== expert.activeSessions) + fixes.push(`Synced activeSessions from ${oldCount} → ${expert.activeSessions}`); + + // --- Ensure valid values --- + if (expert.activeSessions < 0) { + expert.activeSessions = 0; + fixes.push('Fixed negative activeSessions'); + } + + if (expert.maxSessions < expert.activeSessions) { + expert.maxSessions = Math.max(expert.activeSessions, expert.maxSessions); + fixes.push('Expanded maxSessions to match activeSessions'); + } + + // --- Write back only if something changed --- + if (fixes.length > 0) { + this.logger.warn(`[repairRedisConsistency] Writing onlineExperts[${expertId}] fixes: ${fixes.join(', ')}`); + await client.hset(this.ONLINE_EXPERTS_KEY, expertId, JSON.stringify(expert)); + repaired.push({ expertId, fixes }); + } + } + + // --- Reporting --- + if (repaired.length === 0 && orphanedSessions.length === 0) { + this.logger.log('repairRedisConsistency: all entries are consistent ✅'); + } else { + if (repaired.length > 0) { + this.logger.warn( + `repairRedisConsistency: fixed ${repaired.length} experts:\n` + + repaired + .map( + (r) => + ` - ${r.expertId}: ${r.fixes.join(', ')}`, + ) + .join('\n'), + ); + } + if (orphanedSessions.length > 0) { + this.logger.warn(`repairRedisConsistency: found ${orphanedSessions.length} orphaned sessions`); + } + } + + const result: any = repaired.length > 0 ? repaired : undefined; + if (orphanedSessions.length > 0) { + return { ...(result || {}), orphanedSessions: orphanedSessions.length, orphanedSessionIds: orphanedSessions }; + } + return result; + } catch (err) { + this.logger.error('repairRedisConsistency error:', err as any); + throw err; + } +} + +/** + * Detects and fixes duplicate sessions across expert SETs + * A session should only be in one expert's SET at a time + * Uses room data to determine the correct expert assignment + */ +async fixDuplicateSessionsAcrossExperts(): Promise> { + try { + const client = this.client(); + const fixes: Array<{ sessionId: string; removedFrom: string[]; keptIn: string }> = []; + + // Get all experts + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (!expertsRaw || Object.keys(expertsRaw).length === 0) { + return fixes; + } + + // Get all rooms to determine correct expert assignments + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + const sessionToExpertMap = new Map(); + + // Build map of sessionId -> expertId from room data (source of truth) + for (const [roomKey, roomRaw] of Object.entries(roomsRaw || {})) { + try { + const room = JSON.parse(roomRaw as string) as RoomRedisData; + if (room.sessionId && room.expertId) { + sessionToExpertMap.set(room.sessionId, room.expertId); + } + } catch (err) { + // Skip corrupted room data + } + } + + // Track which sessions are in which expert SETs + const sessionToExpertsMap = new Map>(); + + // Check all expert SETs + for (const [expertId] of Object.entries(expertsRaw)) { + try { + const setKey = this.expertSessionsKey(expertId); + const sessionIds = await client.smembers(setKey); + + for (const sessionId of sessionIds) { + if (!sessionToExpertsMap.has(sessionId)) { + sessionToExpertsMap.set(sessionId, new Set()); + } + sessionToExpertsMap.get(sessionId)!.add(expertId); + } + } catch (err) { + this.logger.warn(`Failed to check expert ${expertId} SET for duplicates`, err as any); + } + } + + // Find sessions in multiple expert SETs + for (const [sessionId, expertIds] of sessionToExpertsMap.entries()) { + if (expertIds.size > 1) { + // Session is in multiple expert SETs - need to fix + const expertIdsArray = Array.from(expertIds); + + // Determine correct expert from room data (source of truth) + const correctExpertId = sessionToExpertMap.get(sessionId); + + let expertToKeep: string; + if (correctExpertId && expertIds.has(correctExpertId)) { + // Room says this expert should have it, and it's in their SET - perfect match + expertToKeep = correctExpertId; + } else if (correctExpertId) { + // Room says a different expert should have it, but it's not in their SET + // This means the room and SET are out of sync - fix by using room as source of truth + expertToKeep = correctExpertId; + try { + // Add to correct expert's SET (even if they're not in onlineExperts hash - SET is canonical) + const setKey = this.expertSessionsKey(correctExpertId); + await client.sadd(setKey, sessionId); + // Try to sync hash if expert is online + const expertExists = expertsRaw[correctExpertId]; + if (expertExists) { + await this.updateOnlineExpertHash(correctExpertId, { includeSessionIdsFromSet: true, isOnline: true }); + } + this.logger.log(`[fixDuplicateSessionsAcrossExperts] Added session ${sessionId} to correct expert ${correctExpertId} SET (was missing)`); + } catch (err) { + this.logger.warn(`Failed to add session ${sessionId} to correct expert ${correctExpertId} SET`, err as any); + // Fallback: keep first expert if we can't add to correct one + expertToKeep = expertIdsArray[0]; + this.logger.warn(`[fixDuplicateSessionsAcrossExperts] Fallback: keeping session ${sessionId} in expert ${expertToKeep} SET`); + } + } else { + // No room data - keep the first expert (arbitrary choice) + expertToKeep = expertIdsArray[0]; + this.logger.warn(`[fixDuplicateSessionsAcrossExperts] Session ${sessionId} has no room data, keeping in expert ${expertToKeep} SET`); + } + + // Remove from all other expert SETs + const removedFrom: string[] = []; + for (const expertId of expertIdsArray) { + if (expertId !== expertToKeep) { + try { + const setKey = this.expertSessionsKey(expertId); + await client.srem(setKey, sessionId); + removedFrom.push(expertId); + + // Sync the expert's hash after removal + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`Failed to remove session ${sessionId} from expert ${expertId} SET`, err as any); + } + } + } + + // Sync the expert we kept it in + try { + await this.updateOnlineExpertHash(expertToKeep, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`Failed to sync expert ${expertToKeep} hash after duplicate fix`, err as any); + } + + fixes.push({ + sessionId, + removedFrom, + keptIn: expertToKeep, + }); + + this.logger.warn( + `[fixDuplicateSessionsAcrossExperts] Fixed duplicate session ${sessionId}: ` + + `removed from experts [${removedFrom.join(', ')}], kept in expert ${expertToKeep}` + ); + } + } + + if (fixes.length > 0) { + this.logger.warn(`[fixDuplicateSessionsAcrossExperts] Fixed ${fixes.length} duplicate sessions across experts`); + } else { + this.logger.log('[fixDuplicateSessionsAcrossExperts] No duplicate sessions found'); + } + + return fixes; + } catch (err) { + this.logger.error('fixDuplicateSessionsAcrossExperts error:', err as any); + throw err; + } +} + + // ============================= + // ===== CHAT TRANSFER ========= + // ============================= + + private async loadOnlineExpertsParsed(): Promise { + const client = this.client(); + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (!entries || Object.keys(entries).length === 0) return []; + return Object.values(entries) + .map((v) => { + try { + return JSON.parse(v as string) as ExpertRedisData; + } catch { + return null as any; + } + }) + .filter(Boolean); + } + + private async validateTransferSession( + sessionId: string, + fromExpertId: string, + ): Promise<{ + session: { userId: Types.ObjectId; transferCount?: number; onlineChatClosed?: boolean; chatClosed?: boolean }; + room: RoomRedisData; + userId: string; + }> { + if (!Types.ObjectId.isValid(sessionId) || !Types.ObjectId.isValid(fromExpertId)) { + throw new BadRequestException('Invalid sessionId or expertId'); + } + + const session = await this.sessionModel + .findById(sessionId) + .select('userId transferCount onlineChatClosed chatClosed connectedToExpert') + .lean(); + if (!session) { + throw new BadRequestException('Session not found'); + } + if ((session as any).onlineChatClosed || (session as any).chatClosed) { + throw new BadRequestException('Chat session is closed'); + } + if ((session as any).transferCount >= 1) { + throw new BadRequestException('This conversation has already been transferred once'); + } + + const client = this.client(); + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + throw new BadRequestException('Active chat room not found'); + } + const room = JSON.parse(roomRaw) as RoomRedisData; + if (!room.expertId || String(room.expertId) !== String(fromExpertId)) { + throw new ForbiddenException('You are not the assigned expert for this session'); + } + if (room.isActive === false) { + throw new BadRequestException('Chat room is not active'); + } + + return { + session: session as any, + room, + userId: String((session as any).userId), + }; + } + + async getTransferCandidates( + sessionId: string, + fromExpertId: string, + ): Promise { + await this.validateTransferSession(sessionId, fromExpertId); + + const experts = await this.loadOnlineExpertsParsed(); + const others = experts.filter((e) => e.isOnline && e.expertId !== fromExpertId); + + if (others.length === 0) { + return []; + } + + const result: TransferCandidateDto[] = []; + for (const e of others) { + const max = e.maxSessions || this.MAX_SESSIONS_PER_EXPERT; + const active = e.activeSessions || 0; + const admin = await this.adminModel + .findById(e.expertId) + .select('name family') + .lean(); + result.push({ + expertId: e.expertId, + name: admin?.name ?? '', + family: admin?.family ?? '', + capacityLeft: Math.max(0, max - active), + activeSessions: active, + maxSessions: max, + }); + } + result.sort((a, b) => b.capacityLeft - a.capacityLeft); + return result; + } + + private async applySessionTransfer( + sessionId: string, + userId: string, + fromExpertId: string, + toExpertId: string, + options: { + mode: 'selective' | 'random'; + reason?: string; + source: 'manual' | 'auto'; + incrementTransferCount: boolean; + }, + ): Promise<{ toExpertSocketId: string | null }> { + const client = this.client(); + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + throw new BadRequestException('Room not found during transfer'); + } + + const experts = await this.loadOnlineExpertsParsed(); + const expertsWithSession: string[] = []; + for (const expert of experts) { + if (expert.expertId === toExpertId) continue; + try { + const setKey = this.expertSessionsKey(expert.expertId); + if (await client.sismember(setKey, sessionId)) { + expertsWithSession.push(expert.expertId); + } + } catch { + /* ignore */ + } + } + + const expertsToSync: string[] = []; + for (const expertId of expertsWithSession) { + try { + await this.removeSessionFromExpertSet(expertId, sessionId); + expertsToSync.push(expertId); + } catch (err) { + this.logger.warn(`[applySessionTransfer] SREM failed for ${expertId}`, err as any); + } + } + + if (fromExpertId !== toExpertId) { + try { + await this.removeSessionFromExpertSet(fromExpertId, sessionId); + if (!expertsToSync.includes(fromExpertId)) { + expertsToSync.push(fromExpertId); + } + } catch (err) { + this.logger.warn(`[applySessionTransfer] SREM fromExpert failed`, err as any); + } + } + + try { + const newSetKey = this.expertSessionsKey(toExpertId); + const already = await client.sismember(newSetKey, sessionId); + if (!already) { + await client.sadd(newSetKey, sessionId); + } + } catch (err) { + this.logger.error(`[applySessionTransfer] SADD to ${toExpertId} failed`, err as any); + throw err; + } + + await this.updateOnlineExpertHash(toExpertId, { + includeSessionIdsFromSet: true, + isOnline: true, + }); + for (const expertId of expertsToSync) { + try { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`[applySessionTransfer] hash sync failed for ${expertId}`, err as any); + } + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + room.expertId = toExpertId; + room.isActive = true; + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + const toExpertEmail = + (await this.adminModel.findById(toExpertId).select('email').lean())?.email || + String(toExpertId); + const fromExpertEmail = + (await this.adminModel.findById(fromExpertId).select('email').lean())?.email || + String(fromExpertId); + + const mongoUpdate: Record = { + expert: toExpertEmail, + connectedToExpert: true, + onlineChatClosed: false, + }; + if (options.incrementTransferCount) { + mongoUpdate.transferCount = 1; + } + await this.sessionModel.updateOne({ _id: sessionId }, { $set: mongoUpdate }); + + await this.logReassignment(sessionId, userId, fromExpertEmail, toExpertEmail, { + source: options.source, + mode: options.mode, + reason: options.reason, + }); + + const pendingSeconds = Number(process.env.PENDING_EXPERT_SECONDS || '300'); + await client.set( + `session:${sessionId}:pendingExpertUntil`, + (Date.now() + pendingSeconds * 1000).toString(), + 'EX', + 60 * 60, + ); + await client.set(`session:${sessionId}:expertSeen`, 'false', 'EX', 60 * 60 * 24); + await client.del(`session:${sessionId}:onlyOneExpertSkipped`); + await this.cancelPendingExpertTimer(sessionId); + await this.refreshInactiveChatTimer(sessionId, false); + await client.set( + `session:${sessionId}:lastActivity`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + + await this.removeUserFromQueue(userId); + await this.removeWaitingUser(sessionId); + + const toExpertSocketId = await this.getExpertSocketIdFromRedis(toExpertId); + return { toExpertSocketId }; + } + + private async queueSessionForTransfer( + sessionId: string, + userId: string, + fromExpertId: string, + preferredExpertId: string, + mode: 'selective' | 'random', + reason?: string, + ): Promise { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + await this.removeSessionFromExpertSet(fromExpertId, sessionId); + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, fromExpertId); + if (expertRaw) { + await this.updateOnlineExpertHash(fromExpertId, { + includeSessionIdsFromSet: true, + isOnline: true, + }); + } + + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + room.expertId = null; + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + } + + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $unset: { expert: '' }, + $set: { connectedToExpert: false, transferCount: 1 }, + }, + ); + + const fromExpertEmail = + (await this.adminModel.findById(fromExpertId).select('email').lean())?.email || + String(fromExpertId); + const toExpertEmail = + (await this.adminModel.findById(preferredExpertId).select('email').lean())?.email || + String(preferredExpertId); + await this.logReassignment(sessionId, userId, fromExpertEmail, toExpertEmail, { + source: 'manual', + mode, + reason: reason ? `${reason} (queued)` : 'queued', + }); + + await client.del(`session:${sessionId}:pendingExpertUntil`); + await client.del(`session:${sessionId}:expertSeen`); + + await this.enqueueUser(userId, sessionId, { + preferredExpertId, + source: 'transfer', + }); + await this.addWaitingUser(userId, sessionId); + + return this.getWaitingPosition(userId); + } + + async transferChat(params: { + sessionId: string; + fromExpertId: string; + mode: 'selective' | 'random'; + targetExpertId?: string; + reason?: string; + }): Promise { + const { sessionId, fromExpertId, mode, targetExpertId, reason } = params; + const { userId } = await this.validateTransferSession(sessionId, fromExpertId); + + const experts = await this.loadOnlineExpertsParsed(); + const otherOnline = experts.filter((e) => e.isOnline && e.expertId !== fromExpertId); + if (otherOnline.length === 0) { + throw new BadRequestException('No other experts are online. Transfer is not available.'); + } + + let targetId: string; + + if (mode === 'selective') { + if (!targetExpertId) { + throw new BadRequestException('targetExpertId is required for selective transfer'); + } + if (targetExpertId === fromExpertId) { + throw new BadRequestException('Cannot transfer to yourself'); + } + const candidates = await this.getTransferCandidates(sessionId, fromExpertId); + const match = candidates.find((c) => c.expertId === targetExpertId); + if (!match) { + const online = otherOnline.find((e) => e.expertId === targetExpertId); + if (!online) { + throw new BadRequestException('Selected expert is not online'); + } + const position = await this.queueSessionForTransfer( + sessionId, + userId, + fromExpertId, + targetExpertId, + mode, + reason, + ); + return { + status: 'queued', + sessionId, + roomId: `room:${sessionId}`, + userId, + fromExpertId, + preferredExpertId: targetExpertId, + position, + mode, + reason, + }; + } + targetId = targetExpertId; + } else { + const available = otherOnline.filter( + (e) => + (e.activeSessions || 0) < (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + if (!available.length) { + throw new BadRequestException( + 'All other experts are at capacity. Try selective transfer to queue for a specific expert.', + ); + } + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + targetId = available[0].expertId; + } + + const targetExpert = otherOnline.find((e) => e.expertId === targetId); + const atCapacity = + targetExpert && + (targetExpert.activeSessions || 0) >= + (targetExpert.maxSessions || this.MAX_SESSIONS_PER_EXPERT); + + if (atCapacity) { + const position = await this.queueSessionForTransfer( + sessionId, + userId, + fromExpertId, + targetId, + mode, + reason, + ); + return { + status: 'queued', + sessionId, + roomId: `room:${sessionId}`, + userId, + fromExpertId, + preferredExpertId: targetId, + position, + mode, + reason, + }; + } + + const { toExpertSocketId } = await this.applySessionTransfer( + sessionId, + userId, + fromExpertId, + targetId, + { + mode, + reason, + source: 'manual', + incrementTransferCount: true, + }, + ); + + return { + status: 'transferred', + sessionId, + roomId: `room:${sessionId}`, + userId, + fromExpertId, + toExpertId: targetId, + toExpertSocketId, + mode, + reason, + }; + } + + /** + * After expert socket disconnect grace period, auto-reassign their active sessions. + * Does not increment transferCount (not a manual transfer). + */ + async checkAndReassignDisconnectedExperts(): Promise< + Array<{ + sessionId: string; + oldExpertId: string; + newExpertId: string | null; + userId: string; + }> + > { + const client = this.client(); + const thresholdMs = + Number(process.env.EXPERT_INACTIVE_OFFLINE_SECONDS || '900') * 1000; + const now = Date.now(); + const results: Array<{ + sessionId: string; + oldExpertId: string; + newExpertId: string | null; + userId: string; + }> = []; + + const experts = await this.loadOnlineExpertsParsed(); + for (const expert of experts) { + const expertId = expert.expertId; + const disconnectedRaw = await client.get(`expert:${expertId}:disconnectedAt`); + if (!disconnectedRaw) continue; + + const disconnectedAt = parseInt(disconnectedRaw, 10); + if (!disconnectedAt || now - disconnectedAt < thresholdMs) { + continue; + } + + const sessionIds = await this.filterActiveSessions( + await this.getExpertSessionIdsFromSet(expertId), + ); + + for (const sessionId of sessionIds) { + try { + const session = await this.sessionModel + .findById(sessionId) + .select('userId onlineChatClosed chatClosed') + .lean(); + if (!session || (session as any).onlineChatClosed || (session as any).chatClosed) { + continue; + } + const userId = String((session as any).userId); + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) continue; + + const allExperts = await this.loadOnlineExpertsParsed(); + const available = allExperts.filter( + (e) => + e.isOnline && + e.expertId !== expertId && + (e.activeSessions || 0) < + (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + + if (!available.length) { + this.logger.warn( + `[checkAndReassignDisconnectedExperts] No expert with capacity for session ${sessionId}`, + ); + continue; + } + + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + const newExpert = available[0]; + + await this.applySessionTransfer(sessionId, userId, expertId, newExpert.expertId, { + mode: 'random', + reason: 'expert_disconnect', + source: 'auto', + incrementTransferCount: false, + }); + + results.push({ + sessionId, + oldExpertId: expertId, + newExpertId: newExpert.expertId, + userId, + }); + } catch (err) { + this.logger.warn( + `[checkAndReassignDisconnectedExperts] session ${sessionId} failed`, + err as any, + ); + } + } + + const remaining = await this.filterActiveSessions( + await this.getExpertSessionIdsFromSet(expertId), + ); + if (remaining.length === 0) { + await client.del(`expert:${expertId}:disconnectedAt`); + await this.setExpertOffline(expertId); + } + } + + return results; + } + +async startPendingExpertTimer(sessionId: string, expertId: string) { + const r = this.client(); + await r.set(`${this.PENDING_PREFIX}${sessionId}`, expertId, 'EX', 300); +} + +async refreshInactiveChatTimer(sessionId: string, isExpertMessage: boolean = false) { + const r = this.client(); + const result = await r.set(`${this.INACTIVE_PREFIX}${sessionId}`, sessionId, 'EX', 300); + // Also update the scheduler-observed lastActivity timestamp + await r.set( + `session:${sessionId}:lastActivity`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + + // If expert sent a message, clear the "only one expert skipped" marker + // This prevents false "didn't answer" logs when expert actually responded + if (isExpertMessage) { + try { + const markerKey = `session:${sessionId}:onlyOneExpertSkipped`; + const markerExists = await r.exists(markerKey); + if (markerExists) { + await r.del(markerKey); + this.logger.log( + `[refreshInactiveChatTimer] Cleared onlyOneExpertSkipped marker for session ${sessionId} because expert sent a message`, + ); + } + } catch (err) { + // Non-critical - log but don't fail + this.logger.warn(`[refreshInactiveChatTimer] Failed to clear onlyOneExpertSkipped marker for session ${sessionId}`, err as any); + } + } + + console.log('refreshInactiveChatTimer: refreshed timer and lastActivity for session', sessionId, result); +} + +async handlePendingExpertExpiry(sessionId: string) { + const r = this.client(); + const expertId = await r.get(`${this.PENDING_PREFIX}${sessionId}`); + if (!expertId) return null; + + this.logger.warn(`Expert ${expertId} never joined ${sessionId}`); + + await this.endChat(sessionId); + // For reassignment, we don't know business hours status, so pass false + // This will queue if no experts available (old behavior for reassignment) + const newExpert = await this.assignExpert({ userId: '', sessionId }, false); + if (newExpert.status !== 'assigned') return null; + + await this.startPendingExpertTimer(sessionId, newExpert.expertId); + return { + event: 'expertReassigned', + data: { sessionId, newExpertId: newExpert.expertId }, + }; +} + +async handleInactiveChatExpiry(sessionId: string) { + await this.endChat(sessionId); + return { event: 'chatAutoEnded', data: { sessionId } }; +} + +async cancelPendingExpertTimer(sessionId: string) { + const client = this.client(); + // remove any legacy keys and the active pending key + await client.del(`session:${sessionId}:pendingExpertUntil`); + await client.del(`session:${sessionId}:pendingExpertFor`); + await client.del(`${this.PENDING_PREFIX}${sessionId}`); + return true; +} + +// async setupRedisExpiryListener(onEvent: (evt: any) => void) { +// const c = this.client(); +// await c.configSet('notify-keyspace-events', 'Ex'); +// const sub = c.duplicate(); +// await sub.connect(); +// await sub.psubscribe('__keyevent@0__:expired', async (key: string) => { +// if (key.startsWith(this.PENDING_PREFIX)) { +// const id = key.split(':')[1]; +// onEvent(await this.handlePendingExpertExpiry(id)); +// } else if (key.startsWith(this.INACTIVE_PREFIX)) { +// const id = key.split(':')[1]; +// onEvent(await this.handleInactiveChatExpiry(id)); +// } +// }); +// } + + // ============================= + // ===== CLEANUP FUNCTIONS ===== + // ============================= + + /** + * Cleans up junk rooms with invalid userId or expertId + * This function is safe to run periodically and won't interrupt other operations + */ + async cleanupJunkRooms(): Promise<{ removed: number; errors: number; details: string[] }> { + const client = this.client(); + const stats = { removed: 0, errors: 0, details: [] }; + + try { + this.logger.log('Starting junk rooms cleanup...'); + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + const totalRooms = Object.keys(roomsRaw).length; + this.logger.log(`Found ${totalRooms} rooms to check`); + + for (const [roomKey, raw] of Object.entries(roomsRaw)) { + try { + const room = JSON.parse(raw) as RoomRedisData; + const sessionId = String(room.sessionId); + let shouldRemove = false; + let reason = ''; + + // Check 1: Invalid userId + if (!room.userId || room.userId === 'unknown-user') { + shouldRemove = true; + reason = 'invalid userId (null or unknown-user)'; + } + + // Check 2: Validate userId exists + if (!shouldRemove && room.userId) { + const userExists = await this.userModel.exists({ _id: room.userId }); + if (!userExists) { + shouldRemove = true; + reason = `userId does not exist: ${room.userId}`; + } + } + + // Check 3: expertId is null but session says expert is connected + if (!shouldRemove && room.expertId === null && room.isActive) { + try { + const session = await this.sessionModel + .findById(sessionId) + .select('expert connectedToExpert') + .lean(); + if (session?.connectedToExpert && !session?.expert) { + // Session says expert is connected but expertId is null - invalid state + shouldRemove = true; + reason = 'expertId null but connectedToExpert is true'; + } + } catch (err) { + // If session doesn't exist, we'll catch it in Check 4 + } + } + + // Check 4: Validate expertId if present + if (!shouldRemove && room.expertId) { + // Try to normalize (handles email/username) + const normalized = await this.normalizeExpertId(room.expertId, sessionId); + if (!normalized) { + shouldRemove = true; + reason = `expertId does not exist or invalid: ${room.expertId}`; + } + } + + // Check 5: Validate session exists and is active + if (!shouldRemove) { + const session = await this.sessionModel.findById(sessionId).select('_id chatClosed onlineChatClosed').lean(); + if (!session) { + shouldRemove = true; + reason = 'session does not exist in MongoDB'; + } else if ((session as any).chatClosed || (session as any).onlineChatClosed) { + shouldRemove = true; + reason = 'session is marked as closed in MongoDB'; + } + } + + // Check 6: Validate session is assigned to an expert in onlineExperts + // The source of truth for "active session" is whether an expert has it in their list + if (!shouldRemove && room.expertId) { + try { + // We check the expert's SET of sessions (canonical source) + const setKey = this.expertSessionsKey(String(room.expertId)); + const isAssigned = await client.sismember(setKey, sessionId); + + if (!isAssigned) { + shouldRemove = true; + reason = `session ${sessionId} not found in expert ${room.expertId}'s active session set`; + } + } catch (err) { + // If check fails, be conservative and don't remove yet + } + } + + if (shouldRemove) { + this.logger.warn(`Removing junk room ${roomKey} (session: ${sessionId}): ${reason}`); + await client.hdel(this.ROOMS_HASH_KEY, roomKey); + + // Cleanup auxiliary keys (non-blocking) + try { + await Promise.all([ + client.del(`session:${sessionId}:expertSeen`), + client.del(`session:${sessionId}:pendingExpertUntil`), + client.del(`session:${sessionId}:lastActivity`), + ]); + } catch (err) { + // Ignore errors in cleanup + } + + stats.removed++; + stats.details.push(`${roomKey}: ${reason}`); + } + } catch (err) { + this.logger.error(`Error processing room ${roomKey}:`, err); + stats.errors++; + } + } + + this.logger.log( + `Junk rooms cleanup completed: removed ${stats.removed} rooms, ${stats.errors} errors out of ${totalRooms} total rooms` + ); + return stats; + } catch (err) { + this.logger.error('cleanupJunkRooms error', err as any); + throw err; + } + } + +} diff --git a/src/socket/support-management/dto/create-support-management.dto.ts b/src/socket/support-management/dto/create-support-management.dto.ts new file mode 100644 index 0000000..432be80 --- /dev/null +++ b/src/socket/support-management/dto/create-support-management.dto.ts @@ -0,0 +1,8 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class JoinSupportUser { + @IsString() + userId: string; + @IsEmail() + email: string; +} diff --git a/src/socket/support-management/dto/eventPayloads.dto.ts b/src/socket/support-management/dto/eventPayloads.dto.ts new file mode 100644 index 0000000..39fb417 --- /dev/null +++ b/src/socket/support-management/dto/eventPayloads.dto.ts @@ -0,0 +1,294 @@ +import { + IsString, + IsNotEmpty, + IsIn, + IsMongoId, + IsOptional, + Matches, + IsNumber, + Min, + Max, +} from 'class-validator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { HttpStatus } from '@nestjs/common'; +import { Sender } from 'src/common/types/sender.type'; + +export type UserRole = 'User' | 'Expert' | 'Bot'; + +// ====== COMMON (Validated DTOs) ====== +export class JoinRoomPayload { + @IsOptional() + @IsString() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId?: string; + + @IsString() + @IsMongoId() + userId!: string; + + @IsString() + @IsIn(['User', 'Expert', 'Bot']) + role!: UserRole; +} + +export class LeaveRoomPayload { + @IsString() + @IsNotEmpty() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId!: string; + + @IsString() + @IsMongoId() + userId!: string; + + @IsString() + @IsIn(['User', 'Expert', 'Bot']) + role!: UserRole; +} + +export class NotificationPayload { + @IsIn(['info', 'success', 'warning', 'error']) + type!: 'info' | 'success' | 'warning' | 'error'; + + @IsString() + @IsNotEmpty() + message!: string; +} + +export class ErrorPayload { + @IsString() + code!: string; + + @IsString() + message!: string; +} + +// ====== ONLINE STATUS ====== +export class ExpertOnlinePayload { + @IsString() + @IsMongoId() + expertId!: string; +} + +export class ExpertStatusUpdatePayload { + experts!: { + id: string; + name: string; + available: boolean; + activeSessions: number; + maxSessions: number; + }[]; +} + +// ====== MATCHING / CONNECTION ====== +export class RequestExpertPayload { + @IsString() + @IsMongoId() + userId!: string; + + @IsString() + @IsMongoId() + sessionId!: string; +} + +export class ExpertAssignedPayload { + expertId!: string; + expertName!: string; + roomId!: string; +} + +export class UserAssignedPayload { + userId!: string; + username!: string; + roomId!: string; +} + +export class NoExpertAvailablePayload { + waitingPosition!: number; +} + +export class WaitingListUpdatePayload { + totalWaiting!: number; +} + +export class SendMessagePayload { + @IsOptional() + @IsString() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId?: string; + + @IsString() + @IsMongoId() + sessionId!: string; + + @IsString() + @IsMongoId() + senderId!: string; + + @IsString() + @IsIn(['User', 'Expert', 'Bot']) + senderRole!: UserRole; + + @IsString() + @IsNotEmpty() + message!: string; + + @IsOptional() + @IsIn(['text', 'image', 'voice', 'document']) + type?: 'text' | 'image' | 'voice' | 'document'; + + /** When type is voice: duration in seconds (from upload API or client). Optional but recommended for UI. */ + @IsOptional() + @IsNumber() + @Min(0) + @Max(7200) + voiceDurationSec?: number; + + /** MIME type for voice / image / document messages (optional). */ + @IsOptional() + @IsString() + mimeType?: string; + + /** If set, must reference another message in the same session (online reply / quote). */ + @IsOptional() + @IsString() + @IsMongoId() + replyToMessageId?: string; + + /** + * Object-storage attachments: must match the message id embedded in `chats///...` + * and the row created at upload time (voice/image/document). + */ + @IsOptional() + @IsString() + @IsMongoId() + presetMessageId?: string; +} + +export class ReceiveMessagePayload { + roomId!: string; + senderId!: string; + senderRole!: UserRole; + message!: string; + createdAt!: string; + createdISO!: string; +} + + +// ====== CHAT HISTORY ====== +export class GetChatHistoryPayload { + @IsString() + @IsMongoId() + sessionId!: string; +} + +/** Denormalized quote for replies; `unavailable` is true if the parent message is missing from the session. */ +export interface MessageReplyToSnapshot { + messageId: string; + textPreview: string; + sender: UserRole; + createdISO?: string; + unavailable?: boolean; +} + +export class ChatHistoryPayload { + sessionId!: string; + messages!: { + sender: UserRole; + message: string; + createdAt: string; + messageId?: string; + replyTo?: MessageReplyToSnapshot; + }[]; +} + +export class ActiveChatsPayload { + expertId!: string; + chats!: { + roomId: string; + userId: string; + username: string; + }[]; +} + +export class AssignedExpertPayload { + expertId!: string; + expertName!: string; +} + +// ====== CHAT TRANSFER ====== +export class GetTransferCandidatesPayload { + @IsString() + @IsMongoId() + sessionId!: string; + + @IsString() + @IsMongoId() + expertId!: string; +} + +export class TransferChatPayload { + @IsString() + @IsMongoId() + sessionId!: string; + + @IsString() + @IsMongoId() + expertId!: string; + + @IsString() + @IsIn(['selective', 'random']) + mode!: 'selective' | 'random'; + + @IsOptional() + @IsString() + @IsMongoId() + targetExpertId?: string; + + @IsOptional() + @IsString() + reason?: string; +} + +// ====== SESSION CONTROL ====== +export class EndChatPayload { + @IsString() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId!: string; + + @IsString() + @IsMongoId() + sessionId!: string; +} + +export class ChatEndedPayload { + roomId!: string; + reason?: string; +} + +interface ExpertRedisData { + expertId: string; + socketId: string; + activeSessions: number; + maxSessions: number; +} + + +export class SocketResponsePayload extends BaseResponseDTO { + event: string; + + constructor( + event: string, + statusCode: HttpStatus, + message: string, + data: T, + meta?: any, + ) { + super(statusCode, message, data, meta); + this.event = event; + } +} + +export function createSocketResponse(event: string, data: T, message: string = 'Success', statusCode: HttpStatus = HttpStatus.OK, meta?: any): SocketResponsePayload { + return new SocketResponsePayload(event, statusCode, message, data, meta); +} \ No newline at end of file diff --git a/src/socket/support-management/dto/support-events.dto.ts b/src/socket/support-management/dto/support-events.dto.ts new file mode 100644 index 0000000..5a25826 --- /dev/null +++ b/src/socket/support-management/dto/support-events.dto.ts @@ -0,0 +1,63 @@ +import { IsIn, IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class JoinEventDto { + @IsMongoId() + @IsString() + userId!: string; + + @IsString() + @IsIn(['User', 'Expert']) + role!: 'User' | 'Expert'; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsMongoId() + @IsString() + sessionId!: string; +} + +export class SendMessageEventDto { + @IsMongoId() + @IsString() + senderId!: string; + + @IsMongoId() + @IsString() + receiverId!: string; + + @IsString() + @IsNotEmpty() + message!: string; + + @IsMongoId() + @IsString() + sessionId!: string; +} + +export class EditMessageEventDto { + @IsMongoId() + @IsString() + sessionId!: string; + + @IsString() + @IsNotEmpty() + messageId!: string; + + @IsString() + @IsNotEmpty() + newMessage!: string; + + @IsMongoId() + @IsString() + editorId!: string; +} + +export class LeaveEventDto { + @IsMongoId() + @IsString() + userId!: string; +} + + diff --git a/src/socket/support-management/dto/update-support-management.dto.ts b/src/socket/support-management/dto/update-support-management.dto.ts new file mode 100644 index 0000000..e349d20 --- /dev/null +++ b/src/socket/support-management/dto/update-support-management.dto.ts @@ -0,0 +1,3 @@ +export class UpdateSupportManagementDto { + id: number; +} diff --git a/src/socket/support-management/entities/support-management.entity.ts b/src/socket/support-management/entities/support-management.entity.ts new file mode 100644 index 0000000..00add0c --- /dev/null +++ b/src/socket/support-management/entities/support-management.entity.ts @@ -0,0 +1 @@ +export class SupportManagement {} diff --git a/src/socket/support-management/support-management.gateway.spec.ts b/src/socket/support-management/support-management.gateway.spec.ts new file mode 100644 index 0000000..9d1dea0 --- /dev/null +++ b/src/socket/support-management/support-management.gateway.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SupportManagementGateway } from './support-management.gateway'; +import { SupportManagementService } from './support-management.service'; + +describe('SupportManagementGateway', () => { + let gateway: SupportManagementGateway; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SupportManagementGateway, SupportManagementService], + }).compile(); + + gateway = module.get(SupportManagementGateway); + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + }); +}); diff --git a/src/socket/support-management/support-management.gateway.ts b/src/socket/support-management/support-management.gateway.ts new file mode 100644 index 0000000..c248bac --- /dev/null +++ b/src/socket/support-management/support-management.gateway.ts @@ -0,0 +1,584 @@ +import { InjectModel } from '@nestjs/mongoose'; +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + MessageBody, + ConnectedSocket, +} from '@nestjs/websockets'; +import { Model, Types } from 'mongoose'; +import { Server, Socket } from 'socket.io'; +import { Sender } from 'src/common/types/sender.type'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { AdminService } from '../../api/admin/admin.service'; +import { ConversationService } from '../../conversation/conversation.service'; +import { UserService } from '../../api/user/user.service'; + +interface User { + id: string; + socketId: string; + role: Sender.Expert | Sender.User; + name: string; + sessionId: string; +} + +interface PendingMessage { + senderId: string; + message: string; + sessionId: string; +} + +@WebSocketGateway({ cors: { origin: '*' } }) +export class SupportManagementGateway + implements OnGatewayDisconnect, OnGatewayConnection { + constructor( + private readonly adminService: AdminService, + private readonly userService: UserService, + @InjectModel(SessionModel.name) + private readonly session: Model, + private readonly conversationService: ConversationService, + ) { } + @WebSocketServer() server: Server; + private connectedIps = new Set(); + + private supports: User[] = []; + private users: User[] = []; + private waitingUsers: User[] = []; + private supportAssignments: Record = {}; + private pendingMessages: Record = {}; + private roundRobinIndex: number = 0; + + handleDisconnect(client: Socket) { + console.log( + `Client disconnected: ${client.id} - ${client.handshake.address}`, + ); + + const disconnectedSupport = this.supports.find( + (support) => support.socketId === client.id, + ); + if (disconnectedSupport) { + const assignedUsers = + this.supportAssignments[disconnectedSupport.id] || []; + assignedUsers.forEach((userId) => { + const user = this.users.find((u) => u.id === userId); + if (user) { + this.waitingUsers.push(user); + this.server.to(user.socketId).emit('support_disconnected', { + message: 'پشتیبان شما از سیستم خارج شد. لطفا منتظر بمانید.', + }); + this.userService.toggleExpertActions( + user.id, + user.sessionId, + 'onlineChatClosed', + ); + } + }); + delete this.supportAssignments[disconnectedSupport.id]; + } + + this.supports = this.supports.filter( + (support) => support.socketId !== client.id, + ); + this.users = this.users.filter((user) => user.socketId !== client.id); + + Object.keys(this.supportAssignments).forEach((supportId) => { + this.supportAssignments[supportId] = this.supportAssignments[ + supportId + ].filter((userId) => userId !== client.id); + if (this.supportAssignments[supportId].length === 0) { + delete this.supportAssignments[supportId]; + } + }); + this.server.emit('update_support_list', this.getSupportList()); + } + + handleConnection(client: Socket) { + console.log(`Client connected: ${client.id} - ${client.handshake.address}`); + } + + @SubscribeMessage('join') + async handleJoin( + @MessageBody() + data: { + userId: string; + role: Sender.User | Sender.Expert; + name: string; + sessionId: string; + }, + @ConnectedSocket() client: Socket, + ) { + const user: User = { + id: data.userId, + socketId: client.id, + role: data.role, + name: data.name, + sessionId: data.sessionId, + }; + + if (data.role === Sender.Expert) { + const adminExists = await this.adminService.findOneAdmin({ + _id: new Types.ObjectId(user.id), + }); + if (adminExists) { + this.supports.push(user); + this.supportAssignments[user.id] = []; + this.assignWaitingUsersToSupport(user); + } else { + this.server.to(user.socketId).emit('application_error', { + message: `${user.name}, ${user.id} - ادمین پیدا نشد`, + }); + return; + } + } else { + try { + if (!Types.ObjectId.isValid(user.id)) { + this.server.to(user.socketId).emit('application_error', { + message: `${user.name}, ${user.id} معتبر نمیباشد`, + }); + return; + } + const currentUser = await this.userService.findOneUser({ + _id: new Types.ObjectId(user.id), + }); + if (currentUser == null) { + this.server.to(user.socketId).emit('application_error', { + message: `${user.name}, ${user.id} - کاربر پیدا نشد`, + }); + return; + } + + this.users.push(user); + if (this.supports.length === 0) { + this.waitingUsers.push(user); + this.server.to(user.socketId).emit('waiting_for_support'); + } else { + this.assignToSupport(user); + } + } catch (err) { + this.server.to(user.socketId).emit('application_error', { err: err }); + return; + } + } + + console.log(`${data.role} joined:`, user); + this.server.emit('update_support_list', this.getSupportList()); + } + + private assignWaitingUsersToSupport(support: User) { + while ( + this.waitingUsers.length > 0 && + this.supportAssignments[support.id].length < 4 + ) { + const user = this.waitingUsers.shift(); + if (user) { + this.supportAssignments[support.id].push(user.id); + + this.userService.toggleExpertActions( + user.id, + user.sessionId, + 'connectedToExpert', + support.id, + ); + + this.server.to(user.socketId).emit('assigned_support', { + supportId: support.id, + name: support.name, + }); + this.server.to(support.socketId).emit('new_user_assigned', { + userId: user.id, + name: user.name, + }); + console.log([`chat.${[user.sessionId]}.expert`]); + this.server.to(user.socketId).emit('assigned_message', { + message: `پشتیبان ${support.name} به شما اختصاص داده شد.`, + }); + + this.deliverPendingMessages(user.id, support.socketId); + } + } + } + + private async assignToSupport(user: User) { + try { + if (this.supports.length === 0) { + this.server.to(user.socketId).emit('no_support_available'); + return; + } + let attempts = 0; + let supportId; + const maxAttempts = this.supports.length; + + while (attempts < maxAttempts) { + const availableSupport = this.supports[this.roundRobinIndex]; + + // Skip if support is not available + if (!availableSupport) { + this.roundRobinIndex = + (this.roundRobinIndex + 1) % this.supports.length; + attempts++; + continue; + } + + // Initialize support assignments array if it doesn't exist + if (!this.supportAssignments[availableSupport.id]) { + this.supportAssignments[availableSupport.id] = []; + } + + // Check if support can take more users + if (this.supportAssignments[availableSupport.id].length < 4) { + try { + // Add user to support's assignments + this.supportAssignments[availableSupport.id].push(user.id); + supportId = availableSupport.id; + + // Notify user + this.server.to(user.socketId).emit('assigned_support', { + supportId: availableSupport.id, + name: availableSupport.name, + }); + + // Notify support + this.server + .to(availableSupport.socketId) + .emit('new_user_assigned', { + userId: user.id, + name: user.name, + }); + + // Send assignment message + this.server.to(user.socketId).emit('assigned_message', { + message: `پشتیبان ${availableSupport.name} به شما اختصاص داده شد.`, + }); + + // Update user's expert connection + await this.userService.toggleExpertActions( + user.id, + user.sessionId, + 'connectedToExpert', + supportId, + ); + + // Deliver any pending messages + this.deliverPendingMessages(user.id, availableSupport.socketId); + + // Update round robin index + this.roundRobinIndex = + (this.roundRobinIndex + 1) % this.supports.length; + return; + } catch (error) { + console.error('Error assigning user to support:', error); + // Remove user from assignments if there was an error + if (supportId) { + this.supportAssignments[supportId] = this.supportAssignments[ + supportId + ].filter((id) => id !== user.id); + } + throw error; + } + } + + // Move to next support + this.roundRobinIndex = + (this.roundRobinIndex + 1) % this.supports.length; + attempts++; + } + + // If we get here, no support was available + this.server.to(user.socketId).emit('no_support_available'); + } catch (error) { + console.error('Error in assignToSupport:', error); + this.server.to(user.socketId).emit('application_error', { + message: 'خطا در اختصاص پشتیبان. لطفا دوباره تلاش کنید.', + }); + } + } + + @SubscribeMessage('send_message') + async handleMessage( + @MessageBody() + data: { + senderId: string; + receiverId: string; + message: string; + sessionId: string; + }, + ) { + const allParticipants = [...this.supports, ...this.users]; + const sender = allParticipants.find((u) => u.id === data.senderId); + const receiver = allParticipants.find((u) => u.id === data.receiverId); + + if (!sender || !receiver) { + console.log('گیرنده یا فرستنده پیدا نشد. ذخیره پیام...'); + this.pendingMessages[data.receiverId] = + this.pendingMessages[data.receiverId] || []; + + this.pendingMessages[data.receiverId].push({ + senderId: data.senderId, + message: data.message, + sessionId: data.sessionId, + }); + return; + } + + const senderRole = + sender.role === Sender.User ? Sender.User : Sender.Expert; + await this.userService.insertNewChatNewStructure( + data.sessionId, + data.message, + senderRole, + Date.now() / 1000, + ); + + const targetUserId = sender.role === Sender.User ? sender.id : receiver.id; + const chatCollection = await this.userService.userSessionHistory( + targetUserId, + data.sessionId, + ); + + const messageData = { + data: chatCollection.data, + senderRole: sender.role, + senderId: sender.id, + senderName: sender.name, + receiverRole: receiver.role, + receiverId: receiver.id, + receiverName: receiver.name, + message: data.message, + }; + + this.server.to(sender.socketId).emit('all_session_message', messageData); + this.server.to(receiver.socketId).emit('all_session_message', messageData); + + this.server.to(receiver.socketId).emit('receive_message', messageData); + } + + @SubscribeMessage('edit_message') + async handleEditMessage( + @MessageBody() + data: { + sessionId: string; + messageId: string; + newMessage: string; + editorId: string; + }, + @ConnectedSocket() client: Socket, + ) { + const editor = [...this.supports, ...this.users].find( + (u) => u.id === data.editorId, + ); + + if (!editor || editor.role !== Sender.Expert) { + this.server.to(client.id).emit('application_error', { + message: 'فقط کارشناسان مجاز به ویرایش پیام هستند.', + }); + return; + } + + try { + const updatedMessage = await this.userService.editChatMessage( + data.sessionId, + data.messageId, + data.newMessage, + ); + + if (updatedMessage) { + // Get the full session document to find the user ID + const sessionDoc = await this.session.findById(data.sessionId).exec(); + if (!sessionDoc) { + console.warn(`Session not found for sessionId: ${data.sessionId}`); + this.server.to(client.id).emit('application_error', { + message: 'Session not found.', + }); + return; + } + + // The expert who edited the message. His socket is 'client.id' or could be found in 'this.supports' using 'data.editorId'. + const expertSocketInfo = this.supports.find((s) => s.id === data.editorId); + + // Find the user (receiver in this session) using the userId from the session document. + const userSocketInfo = this.users.find( + (u) => u.id === sessionDoc.userId.toString(), + ); + + if (expertSocketInfo) { + this.server.to(expertSocketInfo.socketId).emit('message_edited', updatedMessage); + } else { + console.warn(`Expert socket not found for editorId: ${data.editorId}`); + } + + if (userSocketInfo) { + this.server.to(userSocketInfo.socketId).emit('message_edited', updatedMessage); + } else { + console.warn(`User socket not found for userId: ${sessionDoc.userId.toString()}`); + } + } else { + this.server.to(client.id).emit('application_error', { + message: 'پیام برای ویرایش پیدا نشد.', + }); + } + } catch (error) { + console.error('Error editing message:', error); + this.server.to(client.id).emit('application_error', { + message: 'خطا در ویرایش پیام.', + }); + } + } + + @SubscribeMessage('leave') + async handleLeave( + @MessageBody() data: { userId: string }, + @ConnectedSocket() client: Socket, + ) { + const user = [...this.supports, ...this.users].find( + (u) => u.id === data.userId, + ); + console.log(user); + if (user) { + this.server + .to(user.socketId) + .emit('disconnected', { message: 'شما از سیستم خارج شدید' }); + client.to(user.socketId).disconnectSockets(); + if (user.sessionId) { + console.log(user.sessionId); + console.log('here before call the start conversation'); + await this.conversationService.startConversation(user.sessionId, true); + } + } + + this.supports = this.supports.filter( + (support) => support.id !== data.userId, + ); + this.users = this.users.filter((user) => user.id !== data.userId); + + Object.keys(this.supportAssignments).forEach((supportId) => { + this.supportAssignments[supportId] = this.supportAssignments[ + supportId + ].filter((userId) => userId !== data.userId); + if (this.supportAssignments[supportId].length === 0) { + delete this.supportAssignments[supportId]; + } + }); + + this.server.emit('update_support_list', this.getSupportList()); + } + + @SubscribeMessage('getSupportList') + supportLists() { + this.server.emit('getSupportList', this.getSupportList()); + } + + @SubscribeMessage('support_get_user_list') + supportGetUserList(@ConnectedSocket() client: Socket) { + const support = this.supports.find((s) => s.socketId === client.id); + if (!support) + this.server + .to(client.id) + .emit('application_error', { message: 'کارشناس پیدا نشد ' }); + const assignedUserIds = this.supportAssignments[support.id]; + if (!assignedUserIds) return []; + + const assignedUsers = this.users.filter((user) => + assignedUserIds.includes(user.id), + ); + this.server.to(client.id).emit('support_get_user_list', assignedUsers); + return assignedUsers; + } + + @SubscribeMessage('getSocketInfo') + applicationSupport() { + console.log('info', { + supportAssignments: this.supportAssignments, + users: this.users, + supports: this.supports, + }); + } + @SubscribeMessage('getAssignedSupport') + handleGetAssignedSupport(@ConnectedSocket() client: Socket) { + const user = this.users.find((u) => u.socketId === client.id); + if (!user) { + this.server + .to(client.id) + .emit('application_error', { message: 'کاربر پیدا نشد' }); + return; + } + + let assignedSupport: User | undefined; + for (const support of this.supports) { + if (this.supportAssignments[support.id]?.includes(user.id)) { + assignedSupport = support; + break; + } + } + + if (assignedSupport) { + this.server.to(client.id).emit('assigned_support_info', { + supportId: assignedSupport.id, + + name: assignedSupport.name, + }); + } else { + this.server.to(client.id).emit('no_assigned_support', { + message: 'هیچ پشتیبانی به شما اختصاص داده نشده است', + }); + } + } + + @SubscribeMessage('get_assigned_users_info') + async handleGetAssignedUsersInfo(@ConnectedSocket() client: Socket) { + const support = this.supports.find((s) => s.socketId === client.id); + if (!support) { + this.server + .to(client.id) + .emit('application_error', { message: 'پشتیبان پیدا نشد' }); + return; + } + + const assignedUserIds = this.supportAssignments[support.id] || []; + const assignedUsers = this.users.filter((user) => + assignedUserIds.includes(user.id), + ); + + const usersWithSessionInfo = await Promise.all( + assignedUsers.map(async (user) => { + let sessionHistory = []; + try { + sessionHistory = await this.session.find({ + userId: new Types.ObjectId(user.id), + _id: user.sessionId && Types.ObjectId.isValid(user.sessionId) ? new Types.ObjectId(user.sessionId) : null, + }); + } catch (error) { + console.error(`Error fetching session history for user ${user.id}:`, error); + // Optionally, you might want to return a default or partial object here + } + return { + userId: user.id, + name: user.name, + sessionId: user.sessionId, + sessionHistory: sessionHistory, + }; + }), + ); + this.server.to(client.id).emit('assigned_users_info', usersWithSessionInfo); + } + + private getSupportList() { + return this.supports.map((support) => ({ + id: support.id, + name: support.name, + assignedUsers: this.supportAssignments[support.id] || [], + })); + } + + private deliverPendingMessages(userId: string, supportSocketId: string) { + const messages = this.pendingMessages[userId] || []; + messages.forEach((msg) => { + this.server.to(supportSocketId).emit('receive_message', { + senderName: msg.senderId, + message: msg.message, + }); + }); + delete this.pendingMessages[userId]; + } +} diff --git a/src/socket/support-management/support-management.module.ts b/src/socket/support-management/support-management.module.ts new file mode 100644 index 0000000..346205d --- /dev/null +++ b/src/socket/support-management/support-management.module.ts @@ -0,0 +1,16 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { SupportManagementService } from './support-management.service'; +import { ChatGateway } from './chat.gateway'; +import { ChatService } from './chat.service'; +import { UserModule } from '../../api/user/user.module'; +import { ConversationModule } from '../../conversation/conversation.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { BusinessHoursModule } from 'src/business-hours/business-hours.module'; +import { AdminModule } from '../../api/admin/admin.module'; + +@Module({ + imports: [DatabaseModule, UserModule, ConversationModule, BusinessHoursModule, forwardRef(() => AdminModule)], + providers: [ChatGateway, SupportManagementService, ChatService], + exports: [ChatService], +}) +export class SupportManagementModule {} diff --git a/src/socket/support-management/support-management.service.spec.ts b/src/socket/support-management/support-management.service.spec.ts new file mode 100644 index 0000000..17f3576 --- /dev/null +++ b/src/socket/support-management/support-management.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SupportManagementService } from './support-management.service'; + +describe('SupportManagementService', () => { + let service: SupportManagementService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SupportManagementService], + }).compile(); + + service = module.get(SupportManagementService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/socket/support-management/support-management.service.ts b/src/socket/support-management/support-management.service.ts new file mode 100644 index 0000000..f59831e --- /dev/null +++ b/src/socket/support-management/support-management.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { UpdateSupportManagementDto } from './dto/update-support-management.dto'; +import { AdminService } from '../../api/admin/admin.service'; + +@Injectable() +export class SupportManagementService { + constructor(private readonly adminService: AdminService) {} + + findAll() { + return `This action returns all supportManagement`; + } + + findOne(id: number) { + return `This action returns a #${id} supportManagement`; + } + + update(id: number, updateSupportManagementDto: UpdateSupportManagementDto) { + return `This action updates a #${id} supportManagement`; + } + + remove(id: number) { + return `This action removes a #${id} supportManagement`; + } +} diff --git a/src/storage/key-builder.helper.ts b/src/storage/key-builder.helper.ts new file mode 100644 index 0000000..7b0b960 --- /dev/null +++ b/src/storage/key-builder.helper.ts @@ -0,0 +1,68 @@ +import { BadRequestException } from '@nestjs/common'; +import { Types } from 'mongoose'; +import type { ChatAttachmentFileType } from './storage.types'; +import { MIME_TO_EXT } from './storage.constants'; + +const OBJECT_ID_HEX = /^[a-fA-F0-9]{24}$/; +const CATEGORY_SAFE = /^[a-zA-Z0-9][a-zA-Z0-9\-_]{0,63}$/; + +export function sanitizeCategorySegment(category: string): string { + const c = (category ?? '').trim(); + if (!c || !CATEGORY_SAFE.test(c)) { + throw new BadRequestException('invalid_dictionary_category'); + } + return c; +} + +export function assertValidObjectIds(...ids: string[]): void { + for (const id of ids) { + if (!id || !OBJECT_ID_HEX.test(id) || !Types.ObjectId.isValid(id)) { + throw new BadRequestException('invalid_object_id'); + } + } +} + +/** + * Object key inside the private bucket (no bucket name prefix). + * Example: dictionaries/lifeInsurance/1746941200.csv + */ +export function buildDictionaryStorageKey(category: string, extWithDot: string): string { + const safeCat = sanitizeCategorySegment(category); + const tsSec = Math.floor(Date.now() / 1000); + const ext = normalizeExt(extWithDot); + return `dictionaries/${safeCat}/${tsSec}${ext}`; +} + +/** + * Example: chats///voice/1746941400.mp3 + */ +export function buildChatAttachmentStorageKey( + sessionId: string, + messageId: string, + fileType: ChatAttachmentFileType, + extWithDot: string, +): string { + assertValidObjectIds(sessionId, messageId); + const tsSec = Math.floor(Date.now() / 1000); + const ext = normalizeExt(extWithDot); + return `chats/${sessionId}/${messageId}/${fileType}/${tsSec}${ext}`; +} + +export function normalizeExt(extWithDot: string): string { + let e = (extWithDot || '').trim().toLowerCase(); + if (!e) return ''; + if (!e.startsWith('.')) e = `.${e}`; + if (!/^\.[a-z0-9]{1,10}$/.test(e)) { + throw new BadRequestException('invalid_storage_extension'); + } + return e; +} + +/** Infer extension from MIME; falls back to .bin only for octet-stream. */ +export function extensionFromMime(mime: string): string { + const m = (mime || '').toLowerCase().trim(); + const ext = MIME_TO_EXT[m]; + if (ext) return ext; + if (m === 'application/octet-stream') return '.bin'; + throw new BadRequestException('unsupported_mime_for_storage'); +} diff --git a/src/storage/storage.constants.ts b/src/storage/storage.constants.ts new file mode 100644 index 0000000..cf931f7 --- /dev/null +++ b/src/storage/storage.constants.ts @@ -0,0 +1,96 @@ +import type { ChatAttachmentFileType } from './storage.types'; + +/** Max sizes (bytes). */ +export const MAX_DICTIONARY_BYTES = 50 * 1024 * 1024; +export const MAX_CHAT_VOICE_BYTES = 25 * 1024 * 1024; +export const MAX_CHAT_IMAGE_BYTES = 15 * 1024 * 1024; +export const MAX_CHAT_DOCUMENT_BYTES = 20 * 1024 * 1024; + +/** Admin dictionary uploads: CSV only (not plain text files). */ +export const DICTIONARY_ALLOWED_MIMES = new Set(['text/csv']); + +/** + * FFmpeg normalizes voice to AAC-LC in MP4 (.m4a / `audio/mp4`) before S3 upload. + */ +export const NORMALIZED_VOICE_MIME = 'audio/mp4'; +export const NORMALIZED_VOICE_EXT = '.m4a'; + +/** MIME → safe extension fallback (avoid trusting original filenames). */ +export const MIME_TO_EXT: Record = { + 'audio/mpeg': '.mp3', + 'audio/mp3': '.mp3', + 'audio/webm': '.webm', + 'audio/wav': '.wav', + 'audio/x-wav': '.wav', + 'audio/wave': '.wav', + 'audio/ogg': '.ogg', + 'audio/mp4': '.m4a', + 'audio/x-m4a': '.m4a', + 'audio/aac': '.aac', + 'audio/flac': '.flac', + 'audio/x-flac': '.flac', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'application/pdf': '.pdf', + 'text/csv': '.csv', + 'text/plain': '.txt', + 'application/octet-stream': '.bin', +}; + +/** Broad acceptance for ingest; FFmpeg is the authoritative decoder. */ +export function isVoiceUploadMimeAccepted(mimeRaw: string | undefined): boolean { + const mime = String(mimeRaw ?? '') + .trim() + .toLowerCase(); + if (!mime || mime === 'application/octet-stream') return true; + if (mime.startsWith('audio/')) return true; + if ( + mime === 'video/webm' || + mime === 'video/mp4' || + mime === 'video/quicktime' + ) { + return true; + } + return false; +} + +const IMAGE_MIMES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +]); + +const DOCUMENT_MIMES = new Set(['application/pdf']); + +export function allowedMimeForChatFileType( + fileType: ChatAttachmentFileType, + mime: string, +): boolean { + const m = mime.trim().toLowerCase(); + switch (fileType) { + case 'voice': + return isVoiceUploadMimeAccepted(mime); + case 'image': + return IMAGE_MIMES.has(m); + case 'document': + return DOCUMENT_MIMES.has(m); + default: + return false; + } +} + +export function maxBytesForChatFileType(fileType: ChatAttachmentFileType): number { + switch (fileType) { + case 'voice': + return MAX_CHAT_VOICE_BYTES; + case 'image': + return MAX_CHAT_IMAGE_BYTES; + case 'document': + return MAX_CHAT_DOCUMENT_BYTES; + default: + return MAX_CHAT_DOCUMENT_BYTES; + } +} diff --git a/src/storage/storage.errors.ts b/src/storage/storage.errors.ts new file mode 100644 index 0000000..a4a0e46 --- /dev/null +++ b/src/storage/storage.errors.ts @@ -0,0 +1,7 @@ +export class StorageConfigurationError extends Error { + readonly name = 'StorageConfigurationError'; +} + +export class StorageUploadError extends Error { + readonly name = 'StorageUploadError'; +} diff --git a/src/storage/storage.module.ts b/src/storage/storage.module.ts new file mode 100644 index 0000000..9f41131 --- /dev/null +++ b/src/storage/storage.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + imports: [ + HttpModule.register({ + timeout: 300_000, + maxRedirects: 0, + }), + ], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/src/storage/storage.service.ts b/src/storage/storage.service.ts new file mode 100644 index 0000000..576aa74 --- /dev/null +++ b/src/storage/storage.service.ts @@ -0,0 +1,217 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import FormData = require('form-data'); +import type { Readable } from 'node:stream'; +import type { StorageBucketKind } from './storage.types'; +import { StorageConfigurationError, StorageUploadError } from './storage.errors'; + +/** + * Alarik object storage HTTP API (not raw S3 XML). + * + * Upload: POST /api/v1/objects?bucket=&prefix= + * multipart field `data` (file), headers X-Access-Key, X-Secret-Key + * Download: POST /api/v1/objects/download JSON { bucket, keys: string[] } + */ +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + + constructor( + private readonly config: ConfigService, + private readonly http: HttpService, + ) {} + + private get baseUrl(): string { + return this.normalizeBaseUrl(this.config.get('OS_URL')); + } + + private normalizeBaseUrl(raw: string | undefined): string { + const s = raw?.trim(); + if (!s) return ''; + if (s.startsWith('http://') || s.startsWith('https://')) + return s.replace(/\/$/, ''); + return `http://${s}`.replace(/\/$/, ''); + } + + /** Prefer OS_ACCESS_KEY / OS_SECRET_KEY; fall back to OS_USER / OS_PASSWORD. */ + private getAccessSecret(): { accessKey: string; secretKey: string } { + const accessKey = + this.config.get('OS_ACCESS_KEY')?.trim() || + this.config.get('OS_USER')?.trim(); + const secretKey = + this.config.get('OS_SECRET_KEY')?.trim() || + this.config.get('OS_PASSWORD')?.trim(); + return { accessKey: accessKey ?? '', secretKey: secretKey ?? '' }; + } + + private authHeaders(): Record { + const { accessKey, secretKey } = this.getAccessSecret(); + if (!accessKey || !secretKey) { + throw new StorageConfigurationError( + 'Object storage missing OS_ACCESS_KEY/OS_SECRET_KEY (or legacy OS_USER/OS_PASSWORD)', + ); + } + return { + 'X-Access-Key': accessKey, + 'X-Secret-Key': secretKey, + }; + } + + private assertConfigured(): void { + if (!this.baseUrl) { + throw new StorageConfigurationError('Object storage missing OS_URL'); + } + const { accessKey, secretKey } = this.getAccessSecret(); + if (!accessKey || !secretKey) { + throw new StorageConfigurationError( + 'Object storage missing OS_ACCESS_KEY/OS_SECRET_KEY (or legacy OS_USER/OS_PASSWORD)', + ); + } + } + + getBucketName(kind: StorageBucketKind): string { + const key = kind === 'private' ? 'OS_BUCKET_PRIVATE' : 'OS_BUCKET_PUBLIC'; + const name = this.config.get(key)?.trim(); + if (!name) { + throw new StorageConfigurationError( + kind === 'private' + ? 'OS_BUCKET_PRIVATE is not configured' + : 'OS_BUCKET_PUBLIC is not configured', + ); + } + return name; + } + + /** + * `prefix` = directory ending in `/`; last segment of `key` is sent as the uploaded file name. + */ + private splitKeyForUpload(objectKey: string): { prefix: string; filename: string } { + const k = (objectKey || '').replace(/^\//, ''); + const i = k.lastIndexOf('/'); + if (i === -1) { + return { prefix: '', filename: k || 'object' }; + } + return { + prefix: k.slice(0, i + 1), + filename: k.slice(i + 1) || 'object', + }; + } + + private formatAxiosError(e: unknown): string { + if (e && typeof e === 'object' && 'response' in e) { + const r = (e as { response?: { status?: number; data?: unknown } }).response; + const data = r?.data; + if (data != null) { + const body = typeof data === 'string' ? data : JSON.stringify(data); + return `HTTP ${r?.status ?? '?'} ${body.slice(0, 500)}`; + } + } + return e instanceof Error ? e.message : String(e); + } + + /** + * Upload into the private bucket via Alarik multipart `data` field. + */ + async putPrivateObject(params: { + key: string; + body: Buffer | Uint8Array | Readable; + contentType: string; + contentLength?: number; + metadata?: Record; + }): Promise { + this.assertConfigured(); + const bucket = this.getBucketName('private'); + const { prefix, filename } = this.splitKeyForUpload(params.key); + const url = `${this.baseUrl}/api/v1/objects`; + + const form = new FormData(); + const appendOpts: FormData.AppendOptions = { + filename, + contentType: params.contentType, + }; + if (params.contentLength !== undefined) { + appendOpts.knownLength = params.contentLength; + } + const body = params.body; + if (Buffer.isBuffer(body)) { + form.append('data', body, appendOpts); + } else if (body instanceof Uint8Array) { + form.append('data', Buffer.from(body), appendOpts); + } else { + form.append('data', body, appendOpts); + } + + try { + await this.http.axiosRef.post(url, form, { + params: { + bucket, + prefix, + }, + headers: { + ...this.authHeaders(), + ...form.getHeaders(), + }, + maxBodyLength: Infinity, + maxContentLength: Infinity, + timeout: 300_000, + validateStatus: (s) => s >= 200 && s < 300, + }); + void params.metadata; + } catch (e: unknown) { + const msg = this.formatAxiosError(e); + this.logger.error(`putPrivateObject failed key=${params.key}`, e as Error); + throw new StorageUploadError(msg); + } + } + + async getObjectReadable(params: { + kind: StorageBucketKind; + key: string; + }): Promise { + this.assertConfigured(); + const bucket = this.getBucketName(params.kind); + const url = `${this.baseUrl}/api/v1/objects/download`; + + try { + const res = await this.http.axiosRef.post( + url, + { bucket, keys: [params.key] }, + { + headers: { + ...this.authHeaders(), + 'Content-Type': 'application/json', + }, + responseType: 'stream', + timeout: 300_000, + validateStatus: (s) => s >= 200 && s < 300, + }, + ); + const stream = res.data as Readable; + if (!stream || typeof stream.pipe !== 'function') { + throw new StorageUploadError('empty_object_body'); + } + return stream; + } catch (e: unknown) { + this.logger.error(`getObjectReadable failed key=${params.key}`, e as Error); + throw new StorageUploadError(this.formatAxiosError(e)); + } + } + + async deletePrivateObject(key: string): Promise { + this.logger.warn( + `deletePrivateObject not mapped to Alarik HTTP API (key=${key}); no-op`, + ); + } + + async presignDownloadUrl(params: { + kind: StorageBucketKind; + key: string; + expiresInSeconds: number; + }): Promise { + this.logger.warn('presigned download URLs not enabled for Alarik HTTP API', params); + throw new StorageUploadError( + 'presigned_download_urls_not_configured_use_download_endpoint_or_proxy', + ); + } +} diff --git a/src/storage/storage.types.ts b/src/storage/storage.types.ts new file mode 100644 index 0000000..2e74833 --- /dev/null +++ b/src/storage/storage.types.ts @@ -0,0 +1,5 @@ +/** Logical bucket tier (maps to configured bucket names). */ +export type StorageBucketKind = 'private' | 'public'; + +/** Chat attachment subdirectory under `chats/...`. */ +export type ChatAttachmentFileType = 'voice' | 'image' | 'document'; diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts new file mode 100644 index 0000000..549fb22 --- /dev/null +++ b/src/upload/upload.controller.ts @@ -0,0 +1,100 @@ +import { createReadStream } from 'node:fs'; +import { + Body, + Controller, + Get, + NotFoundException, + Param, + Post, + Req, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { UploadFileDto } from './uploadFile.dto'; +import { UploadService } from './upload.service'; + +@ApiTags('Upload & Download Center') +@Controller('upload') +@ApiBearerAuth() +export class UploadController { + constructor(private readonly uploadService: UploadService) {} + @Post() + @ApiOperation({ + summary: + 'Upload files all over the app and use the _id where you need to submit the file with the form', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file'], + properties: { + file: { + type: 'string', + format: 'binary', + }, + type: { type: 'string', example: 'csv' }, + category: { type: 'string', example: 'dictionary' }, + }, + }, + }) + @UseInterceptors(FileInterceptor('file')) + uploadFile( + @UploadedFile() file: Express.Multer.File, + @Body() uploadFileDto: UploadFileDto, + @Req() req: Request, + ) { + + return this.uploadService.handleFileUpload(file, uploadFileDto, req); + } + + @Get('stream/:fileId') + @ApiParam({ name: 'fileId', description: 'The ID of the file to download' }) + @ApiResponse({ status: 200, description: 'File downloaded successfully' }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + async stream(@Param('fileId') fileId: string, @Res() res: Response) { + const file = await this.uploadService.getFileMetadata(fileId); + const fileStream = await this.uploadService.stream(fileId); + + res.setHeader('Content-Type', file.mimetype); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${file.originalName}"`, + ); + + fileStream.pipe(res); + } + + @Get('download/:fileId') + @ApiOperation({ summary: 'Download a file by fileId' }) + @ApiParam({ name: 'fileId', description: 'The ID of the file to download' }) + @ApiResponse({ status: 200, description: 'File downloaded successfully' }) + @ApiResponse({ status: 404, description: 'File not found' }) + async downloadFile(@Param('fileId') fileId: string) { + // return this.uploadService.downloadFiles(fileId); + const fileData = await this.uploadService.downloadFiles(fileId); + + if (!fileData) { + throw new NotFoundException('File not found'); + } + + const fileStream = createReadStream(fileData.filePath); + + return new StreamableFile(fileStream, { + disposition: `attachment; filename="${fileData.originalName}"`, + }); + } +} diff --git a/src/upload/upload.module.ts b/src/upload/upload.module.ts new file mode 100644 index 0000000..53fcffb --- /dev/null +++ b/src/upload/upload.module.ts @@ -0,0 +1,40 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { DatabaseModule } from 'src/database/database.module'; +import { UploadController } from './upload.controller'; +import { UploadService } from './upload.service'; + +@Module({ + imports: [ + MulterModule.register({ + storage: diskStorage({ + destination: './uploads', + filename: (req, file, cb) => { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const filename = `${uniqueSuffix}-${file.originalname}`; + cb(null, filename); + }, + }), + fileFilter: (req, file, cb) => { + const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'application/pdf', + 'text/csv', + 'text/plain' + ]; + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); // Accept the file + } else { + cb(null, false); // Reject the file + } + }, + }), + forwardRef(() => DatabaseModule), + ], + controllers: [UploadController], + providers: [UploadService], + exports: [UploadService], // Export for reuse in other modules +}) +export class UploadModule {} diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts new file mode 100644 index 0000000..805aaa4 --- /dev/null +++ b/src/upload/upload.service.ts @@ -0,0 +1,134 @@ +import { createReadStream } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { AdminModel } from 'src/database/model/admin.model'; +import { FileUploadModel } from 'src/database/model/fileUpload.model'; + +@Injectable() +export class UploadService { + constructor( + @InjectModel(FileUploadModel.name) + private readonly fileModel: Model, + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + ) {} + async handleFileUpload(file: Express.Multer.File, metadata: any, req) { + try { + // Validate file type + const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'application/pdf', + 'text/csv', + 'text/plain', + ]; + if (!file || !allowedMimeTypes.includes(file.mimetype)) { + throw new HttpException('invalid_file_type', HttpStatus.BAD_REQUEST); + } + + // Validate file size (e.g., max 5MB) + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + throw new HttpException('file_large', HttpStatus.BAD_REQUEST); + } + + // File is valid, return file path and metadata + const filePath = join('./uploads', file.filename); + + const fileData = await this.fileModel.create({ + originalName: file.originalname, + filename: file.filename, + filePath: filePath, + mimetype: file.mimetype, + size: file.size, + type: metadata.type, + category: metadata.category, + }); + + if (metadata.category === 'avatar' && req.user) { + const userId = req.user._id; + + const user = await this.adminModel.findById(userId); + + if (user && user.avatar) { + await this.deleteFile(user.avatar.toString()); + } + + await this.adminModel.findByIdAndUpdate(userId, { + avatar: fileData._id, + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: fileData._id, + name: fileData.filename, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async stream(fileId: string) { + try { + const file = await this.fileModel.findOne({ _id: fileId }); + if (!file) { + throw new BaseResponseDTO(HttpStatus.NOT_FOUND, 'File not found', null); + } + return createReadStream(join(process.cwd(), `${file.filePath}`)); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async getFileMetadata(fileId: string) { + try { + const file = await this.fileModel.findOne({ _id: fileId }); + if (!file) { + throw new BaseResponseDTO(HttpStatus.NOT_FOUND, 'File not found', null); + } + return file; // Return the file metadata + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async downloadFiles(fileId: string) { + try { + const file = await this.fileModel.findOne({ _id: fileId }); + + if (!file) + throw new HttpException('fild_not_found', HttpStatus.NOT_FOUND); + return { + filePath: `http://${process.env.BASEURL}:${process.env.PORT}/${file.filePath}`, + originalName: file.originalName, + }; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async deleteFile(fileId: string): Promise { + try { + const file = await this.fileModel.findById(fileId); + + if (file) { + await unlink(file.filePath); + + await this.fileModel.findByIdAndDelete(fileId); + } + } catch (error) { + console.error( + `Failed to delete old avatar file (ID: ${fileId}):`, + error.message, + ); + } + } +} diff --git a/src/upload/uploadFile.dto.ts b/src/upload/uploadFile.dto.ts new file mode 100644 index 0000000..d7ef462 --- /dev/null +++ b/src/upload/uploadFile.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UploadFileDto { + @IsNotEmpty() + @IsString() + type: string; // e.g., 'image', 'document' + + @IsNotEmpty() + @IsString() + category: string; // e.g., 'profile', 'invoice' +} diff --git a/src/user-management/dto/create-user-management.dto.ts b/src/user-management/dto/create-user-management.dto.ts new file mode 100644 index 0000000..8f588c0 --- /dev/null +++ b/src/user-management/dto/create-user-management.dto.ts @@ -0,0 +1 @@ +export class CreateUserManagementDto {} diff --git a/src/user-management/dto/find-experts-filter.dto.ts b/src/user-management/dto/find-experts-filter.dto.ts new file mode 100644 index 0000000..d6f8cce --- /dev/null +++ b/src/user-management/dto/find-experts-filter.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export enum ActivityType { + START_ACTIVITY = 'startActivity', + LAST_ACTIVITY = 'lastActivity', +} + +export class FindExpertsFilterDto { + @ApiProperty({ + description: 'Filter by average rating (1-5)', + required: false, + example: '4', + }) + @IsOptional() + @IsString() + rate?: string; + + @ApiProperty({ + description: + 'Filter by session date range in Persian format (YYYY/MM/DD-YYYY/MM/DD)', + required: false, + example: '1403/07/01-1403/11/07', + }) + @IsOptional() + @IsString() + date?: string; + + @ApiProperty({ + description: 'Filter by activity type', + required: false, + enum: ActivityType, + example: ActivityType.START_ACTIVITY, + }) + @IsOptional() + @IsEnum(ActivityType) + activityType?: ActivityType; + + @ApiProperty({ + description: 'Search by expert mobile or other fields', + required: false, + example: '09999985840', + }) + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/user-management/dto/update-user-management.dto.ts b/src/user-management/dto/update-user-management.dto.ts new file mode 100644 index 0000000..b401ca5 --- /dev/null +++ b/src/user-management/dto/update-user-management.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateUserManagementDto } from './create-user-management.dto'; + +export class UpdateUserManagementDto extends PartialType( + CreateUserManagementDto, +) {} diff --git a/src/user-management/user-management.controller.ts b/src/user-management/user-management.controller.ts new file mode 100644 index 0000000..129a205 --- /dev/null +++ b/src/user-management/user-management.controller.ts @@ -0,0 +1,250 @@ +import { Controller, Get, Param, UseGuards, Query, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminModel } from 'src/database/model/admin.model'; +import { + FindExpertsFilterDto, + ActivityType, +} from './dto/find-experts-filter.dto'; +import { UserManagementService } from './user-management.service'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@ApiTags('user-management-module') +@Controller('user-management') +export class UserManagementController { + constructor(private readonly userManagementService: UserManagementService) {} + + @Permissions(Permission.UsersList) + @ApiOperation({ + summary: + 'Get all the users which is connected to current expert before or get all the conversations for a expert if using expert filter', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: 'Expert username filter for admin', + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (starts from 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of items per page', + example: 50, + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: 'Filter by date range in Persian format (YYYY/MM/DD-YYYY/MM/DD). Filters users whose firstQuestionDate or lastQuestionDate falls within the range.', + example: '1403/07/01-1403/11/07', + }) + @Get('list') + findAllAnsweredUsers( + @AdminIdentity() adminIdentity: AdminModel, + @Query('user') user?: string, + @Query('expert') expert?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('date') date?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? parseInt(limit, 10) : 50; + + return this.userManagementService.findAllAnsweredUsers(adminIdentity, { + user, + expert, + page: pageNum, + limit: limitNum, + date, + }); + } + + @Permissions(Permission.UsersExport) + @ApiOperation({ + summary: 'Export users list to Excel', + description: 'Exports all users matching the filters to an Excel file. Includes the same data as the list endpoint but without pagination.', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: 'Expert username filter for admin', + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: 'Filter by date range in Persian format (YYYY/MM/DD-YYYY/MM/DD). Filters users whose firstQuestionDate or lastQuestionDate falls within the range.', + example: '1403/07/01-1403/11/07', + }) + @Get('list/export-excel') + async exportUsersToExcel( + @AdminIdentity() adminIdentity: AdminModel, + @Query('user') user?: string, + @Query('expert') expert?: string, + @Query('date') date?: string, + @Res() res?: Response, + ) { + const buffer = await this.userManagementService.exportUsersToExcel(adminIdentity, { + user, + expert, + date, + }); + + const filename = `users-list-${new Date().getTime()}.xlsx`; + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + + @Permissions(Permission.UsersList) + @ApiOperation({ + summary: 'Get the list of the sessions of one user', + }) + @ApiParam({ name: 'userId' }) + @ApiQuery({ + name: 'mobile', + required: false, + type: String, + description: 'user mobile', + example: '09226187419', + }) + @Get('list/:userId') + findUserSessions( + @Param('userId') userId: string, + @AdminIdentity() adminIdentity: AdminModel, + @Query('mobile') mobile?: string, + ) { + return this.userManagementService.findUserSessions( + userId, + adminIdentity, + mobile, + ); + } + + @Permissions(Permission.ExpertsList) + @ApiOperation({ + summary: 'Get all experts with optional filtering', + description: + 'Returns a list of all experts with their session statistics. Can be filtered by rate, date range, and activity type.', + }) + @ApiQuery({ + name: 'rate', + required: false, + type: Number, + description: + 'Filter by average rating (1-5). Returns experts with average rating equal to the specified value (floored).', + example: 4, + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by session date range in Persian format (YYYY/MM/DD-YYYY/MM/DD)', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'activityType', + required: false, + enum: ['startActivity', 'lastActivity'], + description: + 'Filter by activity type. startActivity: sessions started in date range, lastActivity: sessions ended in date range', + example: 'startActivity', + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'search by the expert mobile or others fields', + example: '09999985840', + }) + @Get('/experts/list') + async findAllExperts( + @AdminIdentity() adminIdentity: AdminModel, + @Query('rate') rate?: string, + @Query('date') date?: string, + @Query('activityType') activityType?: ActivityType, + @Query('search') search?: string, + ) { + // Convert empty string to undefined + const effectiveRate = rate?.trim() ? rate : undefined; + const effectiveDate = date?.trim() ? date : undefined; + const effectiveSearch = search?.trim() ? search : undefined; + const effectiveActivityType = activityType?.trim() + ? activityType + : undefined; + + const filter: FindExpertsFilterDto = { + rate: effectiveRate, + date: effectiveDate, + search: effectiveSearch, + activityType: effectiveActivityType, + }; + + return this.userManagementService.findExpertsByFilter( + adminIdentity, + filter, + ); + } + + @Permissions(Permission.ExpertsReport) + @ApiOperation({ + summary: 'Admin gets an expert functionality report', + }) + @ApiParam({ name: 'expertId' }) + @Get('/experts/:expertId/report') + expertReports( + @Param('expertId') expertId: string, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.userManagementService.expertReports(expertId); + } + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: 'Expert can get the history of all his chats', + }) + @Get('/experts/sessions') + expertAllSessions(@AdminIdentity() adminIdentity: AdminModel) { + return this.userManagementService.expertAllSessions(adminIdentity); + } +} diff --git a/src/user-management/user-management.module.ts b/src/user-management/user-management.module.ts new file mode 100644 index 0000000..dc3c17d --- /dev/null +++ b/src/user-management/user-management.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { UserManagementController } from './user-management.controller'; +import { UserManagementService } from './user-management.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [UserManagementController], + providers: [UserManagementService], + exports: [UserManagementService], +}) +export class UserManagementModule {} diff --git a/src/user-management/user-management.service.ts b/src/user-management/user-management.service.ts new file mode 100644 index 0000000..661f730 --- /dev/null +++ b/src/user-management/user-management.service.ts @@ -0,0 +1,1063 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO, PageOptionsDto, PageMetaDto } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { ActivityType } from './dto/find-experts-filter.dto'; +import { Role } from 'src/common/types/role.type'; +import { RedisService } from 'src/common/helpers/redis.service'; +import * as ExcelJS from 'exceljs'; + +@Injectable() +export class UserManagementService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + @InjectModel(AdminModel.name) private readonly admin: Model, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogs: Model, + private readonly redisService: RedisService, + ) {} + + async findAllAnsweredUsers(adminIdentity, filter) { + try { + // Build session query + const sessionQuery: any = { + // connectedToExpert: true, + // onlineChatClosed: true, + }; + + if (adminIdentity.userData.role === Role.Expert) { + sessionQuery.expert = adminIdentity.userData.username; + } else if (filter.expert) { + sessionQuery.expert = filter.expert; + } + + // Fetch sessions with only required fields, lean for performance + const sessions = await this.session + .find(sessionQuery) + .select('userId messages expertRate') + .lean() + .exec(); + + if (!sessions.length) { + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', []); + } + + // Group sessions by userId for faster lookup + const sessionsByUser = new Map(); + const userIdSet = new Set(); + + for (const session of sessions) { + const userId = session.userId.toString(); + userIdSet.add(userId); + + if (!sessionsByUser.has(userId)) { + sessionsByUser.set(userId, []); + } + sessionsByUser.get(userId)!.push(session); + } + + // Build user query + const userQuery: any = { _id: { $in: Array.from(userIdSet) } }; + if (filter.user) { + userQuery.mobile = filter.user; + } + + // Fetch users with only required fields, lean for performance + const users = await this.user + .find(userQuery) + .select('_id name family mobile') + .lean() + .exec(); + + // Process users - no async needed, all data is in memory + const userStats = users.map((user) => { + const userId = user._id.toString(); + const userSessions = sessionsByUser.get(userId) || []; + + // Count total sessions (not messages) + let totalAsked = userSessions.length; + + // Single pass through all messages to find first/last question dates + let firstQuestionDate: [string, string] | null = null; + let lastQuestionDate: [string, string] | null = null; + let firstQuestionTime: number | null = null; + let lastQuestionTime: number | null = null; + let rateSum = 0; + let rateCount = 0; + + for (const session of userSessions) { + const messages = session.messages || []; + + for (const msg of messages) { + if (msg.sender === 'User') { + + // Track first and last question dates using createdISO for accurate comparison + if (msg.createdISO) { + const msgTime = new Date(msg.createdISO).getTime(); + + if (!firstQuestionTime || msgTime < firstQuestionTime) { + firstQuestionTime = msgTime; + firstQuestionDate = msg.createdAt || null; + } + + if (!lastQuestionTime || msgTime > lastQuestionTime) { + lastQuestionTime = msgTime; + lastQuestionDate = msg.createdAt || null; + } + } + } + } + + // Accumulate rates + if (session.expertRate !== undefined && session.expertRate !== null) { + rateSum += session.expertRate; + rateCount++; + } + } + + const averageRate = rateCount > 0 ? Math.round(rateSum / rateCount) : 0; + + return { + _id: user._id, + name: user.name, + family: user.family, + mobile: user.mobile, + firstQuestionDate, + lastQuestionDate, + firstQuestionTime, // Store timestamp for date range filtering + lastQuestionTime, // Store timestamp for date range filtering + totalAsked, + averageRate, + }; + }); + + // Apply date range filter if provided + let filteredUserStats = userStats; + if (filter.date) { + const dateParts = filter.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + null, + ); + } + + const [startDateStr, endDateStr] = dateParts.map(d => d.trim()); + const startISO = TimeHelper.jalaliToISO(startDateStr); + const endISO = TimeHelper.jalaliToISO(endDateStr); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const startTime = startDateObj.getTime(); + const endTime = endDateObj.getTime(); + + // Filter users where either firstQuestionTime or lastQuestionTime falls within the range + // Use the stored timestamps (from createdISO) for accurate comparison + filteredUserStats = userStats.filter((user) => { + const firstTime = user.firstQuestionTime; + const lastTime = user.lastQuestionTime; + + // Include user if either first or last question timestamp is within range + const firstInRange = firstTime !== null && firstTime >= startTime && firstTime <= endTime; + const lastInRange = lastTime !== null && lastTime >= startTime && lastTime <= endTime; + + return firstInRange || lastInRange; + }); + } + + // Apply pagination + const page = filter.page || 1; + const limit = filter.limit || 50; + const skip = (page - 1) * limit; + const totalCount = filteredUserStats.length; + + // Sort by lastQuestionDate (newest first), then by firstQuestionDate + filteredUserStats.sort((a, b) => { + // Helper function to safely get date string from date array + const getDateString = (dateArray: [string, string] | null): string | null => { + if (!dateArray || !Array.isArray(dateArray) || dateArray.length < 2) { + return null; + } + return dateArray[1] || null; + }; + + // If both have lastQuestionDate with valid date strings, compare them + const dateA = getDateString(a.lastQuestionDate); + const dateB = getDateString(b.lastQuestionDate); + + if (dateA && dateB) { + // Compare dates in reverse order (newest first) + return dateB.localeCompare(dateA); + } + + // If only one has lastQuestionDate, prioritize it + if (dateA && !dateB) return -1; + if (!dateA && dateB) return 1; + + // If neither has lastQuestionDate, compare by firstQuestionDate + const firstDateA = getDateString(a.firstQuestionDate); + const firstDateB = getDateString(b.firstQuestionDate); + + if (firstDateA && firstDateB) { + return firstDateB.localeCompare(firstDateA); + } + + // If only one has firstQuestionDate, prioritize it + if (firstDateA && !firstDateB) return -1; + if (!firstDateA && firstDateB) return 1; + + return 0; + }); + + // Apply pagination to sorted results + const paginatedUserStats = filteredUserStats.slice(skip, skip + limit); + + // Remove the timestamp fields from the response (they were only for filtering) + const cleanedResults = paginatedUserStats.map(({ firstQuestionTime, lastQuestionTime, ...rest }) => rest); + + // Create pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: page, + take: limit, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: totalCount }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', cleanedResults, meta); + } catch (err) { + console.error('Error in findAllAnsweredUsers:', err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async exportUsersToExcel(adminIdentity, filter): Promise { + try { + // Reuse the same logic as findAllAnsweredUsers but get all users (no pagination) + // Build session query + const sessionQuery: any = {}; + + if (adminIdentity.userData.role === Role.Expert) { + sessionQuery.expert = adminIdentity.userData.username; + } else if (filter.expert) { + sessionQuery.expert = filter.expert; + } + + // Fetch sessions with only required fields, lean for performance + const sessions = await this.session + .find(sessionQuery) + .select('userId messages expertRate') + .lean() + .exec(); + + if (!sessions.length) { + // Return empty Excel file + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + const sheet = workbook.addWorksheet('Users List'); + sheet.getCell('A1').value = 'No users found'; + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } + + // Group sessions by userId for faster lookup + const sessionsByUser = new Map(); + const userIdSet = new Set(); + + for (const session of sessions) { + const userId = session.userId.toString(); + userIdSet.add(userId); + + if (!sessionsByUser.has(userId)) { + sessionsByUser.set(userId, []); + } + sessionsByUser.get(userId)!.push(session); + } + + // Build user query + const userQuery: any = { _id: { $in: Array.from(userIdSet) } }; + if (filter.user) { + userQuery.mobile = filter.user; + } + + // Fetch users with only required fields, lean for performance + const users = await this.user + .find(userQuery) + .select('_id name family mobile') + .lean() + .exec(); + + // Process users - same logic as findAllAnsweredUsers + const userStats = users.map((user) => { + const userId = user._id.toString(); + const userSessions = sessionsByUser.get(userId) || []; + + let totalAsked = userSessions.length; + + let firstQuestionDate: [string, string] | null = null; + let lastQuestionDate: [string, string] | null = null; + let firstQuestionTime: number | null = null; + let lastQuestionTime: number | null = null; + let rateSum = 0; + let rateCount = 0; + + for (const session of userSessions) { + const messages = session.messages || []; + + for (const msg of messages) { + if (msg.sender === 'User') { + if (msg.createdISO) { + const msgTime = new Date(msg.createdISO).getTime(); + + if (!firstQuestionTime || msgTime < firstQuestionTime) { + firstQuestionTime = msgTime; + firstQuestionDate = msg.createdAt || null; + } + + if (!lastQuestionTime || msgTime > lastQuestionTime) { + lastQuestionTime = msgTime; + lastQuestionDate = msg.createdAt || null; + } + } + } + } + + if (session.expertRate !== undefined && session.expertRate !== null) { + rateSum += session.expertRate; + rateCount++; + } + } + + const averageRate = rateCount > 0 ? Math.round(rateSum / rateCount) : 0; + + return { + _id: user._id, + name: user.name, + family: user.family, + mobile: user.mobile, + firstQuestionDate, + lastQuestionDate, + firstQuestionTime, + lastQuestionTime, + totalAsked, + averageRate, + }; + }); + + // Apply date range filter if provided + let filteredUserStats = userStats; + if (filter.date) { + const dateParts = filter.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + null, + ); + } + + const [startDateStr, endDateStr] = dateParts.map(d => d.trim()); + const startISO = TimeHelper.jalaliToISO(startDateStr); + const endISO = TimeHelper.jalaliToISO(endDateStr); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const startTime = startDateObj.getTime(); + const endTime = endDateObj.getTime(); + + filteredUserStats = userStats.filter((user) => { + const firstTime = user.firstQuestionTime; + const lastTime = user.lastQuestionTime; + + const firstInRange = firstTime !== null && firstTime >= startTime && firstTime <= endTime; + const lastInRange = lastTime !== null && lastTime >= startTime && lastTime <= endTime; + + return firstInRange || lastInRange; + }); + } + + // Sort by lastQuestionDate (newest first), then by firstQuestionDate + filteredUserStats.sort((a, b) => { + const getDateString = (dateArray: [string, string] | null): string | null => { + if (!dateArray || !Array.isArray(dateArray) || dateArray.length < 2) { + return null; + } + return dateArray[1] || null; + }; + + const dateA = getDateString(a.lastQuestionDate); + const dateB = getDateString(b.lastQuestionDate); + + if (dateA && dateB) { + return dateB.localeCompare(dateA); + } + + if (dateA && !dateB) return -1; + if (!dateA && dateB) return 1; + + const firstDateA = getDateString(a.firstQuestionDate); + const firstDateB = getDateString(b.firstQuestionDate); + + if (firstDateA && firstDateB) { + return firstDateB.localeCompare(firstDateA); + } + + if (firstDateA && !firstDateB) return -1; + if (!firstDateA && firstDateB) return 1; + + return 0; + }); + + // Create Excel workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet('Users List'); + + // Add title + sheet.mergeCells('A1:F1'); + sheet.getCell('A1').value = 'Users List'; + sheet.getCell('A1').font = { size: 16, bold: true }; + sheet.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle' }; + sheet.getRow(1).height = 30; + + // Add filter info if provided + let currentRow = 3; + if (filter.date) { + sheet.mergeCells(`A${currentRow}:F${currentRow}`); + sheet.getCell(`A${currentRow}`).value = `Date Range: ${filter.date}`; + sheet.getCell(`A${currentRow}`).font = { size: 12, bold: true }; + sheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + if (filter.expert) { + sheet.mergeCells(`A${currentRow}:F${currentRow}`); + sheet.getCell(`A${currentRow}`).value = `Expert: ${filter.expert}`; + sheet.getCell(`A${currentRow}`).font = { size: 11, italic: true }; + sheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + if (filter.user) { + sheet.mergeCells(`A${currentRow}:F${currentRow}`); + sheet.getCell(`A${currentRow}`).value = `User Mobile: ${filter.user}`; + sheet.getCell(`A${currentRow}`).font = { size: 11, italic: true }; + sheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + + currentRow += 1; + + // Add headers + const headerRow = currentRow; + const headers = ['Mobile', 'Name', 'Family', 'First Question Date', 'Last Question Date', 'Total Asked', 'Average Rate']; + const headerRowObj = sheet.getRow(headerRow); + headerRowObj.values = headers; + headerRowObj.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + headerRowObj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + headerRowObj.alignment = { horizontal: 'center', vertical: 'middle' }; + headerRowObj.height = 25; + + // Helper function to format date array + const formatDate = (dateArray: [string, string] | null): string => { + if (!dateArray || !Array.isArray(dateArray) || dateArray.length < 2) { + return 'N/A'; + } + // Return the date part (second element) and time part (first element) + return `${dateArray[0]} - ${dateArray[1]}`; + }; + + // Add data rows + filteredUserStats.forEach((user, index) => { + const row = sheet.getRow(headerRow + 1 + index); + row.values = [ + user.mobile, + user.name || 'N/A', + user.family || 'N/A', + formatDate(user.firstQuestionDate), + formatDate(user.lastQuestionDate), + user.totalAsked, + user.averageRate, + ]; + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet.columns.forEach((column) => { + column.width = 20; + }); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } catch (err) { + console.error('Error in exportUsersToExcel:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Failed to generate Excel file', + null, + ); + } + } + + async findUsersByExpert(expertUsername, userFilter) { + const matchStage: any = { + chat: { + $elemMatch: { + expert: expertUsername, + }, + }, + }; + if (userFilter) { + matchStage.mobile = userFilter; + } + + return await this.user + .aggregate([ + { + $match: matchStage, + }, + { + $project: { + userId: '$_id', // Rename _id to userId + mobile: 1, + chatLength: { $size: '$chat' }, + createdAt: 1, + updatedAt: 1, + rate: { + $avg: { + $map: { + input: { + $filter: { + input: '$chat', + as: 'chatItem', + cond: { + $and: [ + { $eq: ['$$chatItem.connectedToExpert', true] }, + { $ne: ['$$chatItem.expertRate', null] }, + ], + }, + }, + }, + as: 'filteredChat', + in: '$$filteredChat.expertRate', + }, + }, + }, + }, + }, + ]) + .exec(); + } + + formatUsersWithPersianDates(users) { + return users.map((user) => { + const { createdAt, updatedAt, _id, ...rest } = user; + return { + ...rest, // Include all other fields except createdAt and updatedAt + first: TimeHelper.unix2PersianTimeAndDate( + new Date(createdAt).getTime() / 1000, + ), + last: TimeHelper.unix2PersianTimeAndDate( + new Date(updatedAt).getTime() / 1000, + ), + }; + }); + } + + async findUserSessions(userId: string, adminIdentity, mobile: string) { + try { + const user = await this.user.findOne({ _id: userId }); + if (!user) + throw new HttpException('user_not_found', HttpStatus.NOT_FOUND); + + const filteredChats = user.chat.filter((chat) => { + return ( + chat.connectedToExpert === true && + chat.expert === adminIdentity.userData.username + ); + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', filteredChats); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async expertReports(expertId) { + try { + const admin = await this.admin.findOne( + { _id: new Types.ObjectId(expertId), isActive: true }, + { password: 0, resetToken: 0 }, + ); + + if (!admin) { + throw new HttpException('not_found', HttpStatus.NOT_FOUND); + } + + // Use expert's email to find sessions (expert field stores email) + const expertEmail = admin.email; + + // Calculate total answered: sessions where expert actually connected (connectedToExpert === true) + // and the expert field matches this expert's email + const answeredSessions = await this.session.find({ + expert: expertEmail, + connectedToExpert: true, + onlineStartDate: { $exists: true }, + }); + + // Calculate total answered (sessions where expert connected) + const totalAnswered = answeredSessions.length; + + // Calculate total not answered: count from reassigned_logs where this expert missed the chat + // (reassigned from them, meaning they were online but didn't answer) + const totalNotAnswered = await this.reassignedLogs.countDocuments({ + from: expertEmail, + }); + + // Calculate total sessions (answered + not answered) + const totalSessions = totalAnswered + totalNotAnswered; + + // Calculate average waiting time for expert first response (only from answered sessions) + let totalWaitingTime = 0; + let sessionsWithExpertResponse = 0; + + for (const session of answeredSessions) { + if (session.onlineStartDate && session.messages.length > 0) { + const firstExpertMessage = session.messages.find( + (message) => message.sender === 'Expert', + ); + + if (firstExpertMessage && firstExpertMessage.createdISO) { + const onlineStartDate = new Date(session.onlineStartDate); + const expertResponseTime = new Date(firstExpertMessage.createdISO); + + const waitingTime = expertResponseTime.getTime() - onlineStartDate.getTime(); + if (waitingTime >= 0) { + totalWaitingTime += waitingTime; + sessionsWithExpertResponse++; + } + } + } + } + + const waitingAvgTime = sessionsWithExpertResponse > 0 + ? Math.round(totalWaitingTime / sessionsWithExpertResponse / 1000) + : 0; + + // For activity time, first filter for answered sessions that have valid date ranges. + const sessionsForActivity = answeredSessions.filter( + (session) => session.onlineStartDate && session.onlineEndDate, + ); + + let activityTime = 0; + if (sessionsForActivity.length > 0) { + const sortedSessions = sessionsForActivity.sort( + (a, b) => a.onlineStartDate.getTime() - b.onlineStartDate.getTime(), + ); + + const mergedIntervals = [ + { + start: sortedSessions[0].onlineStartDate, + end: sortedSessions[0].onlineEndDate, + }, + ]; + + for (let i = 1; i < sortedSessions.length; i++) { + const currentSession = sortedSessions[i]; + const lastMerged = mergedIntervals[mergedIntervals.length - 1]; + + if (currentSession.onlineStartDate <= lastMerged.end) { + if (currentSession.onlineEndDate > lastMerged.end) { + lastMerged.end = currentSession.onlineEndDate; + } + } else { + mergedIntervals.push({ + start: currentSession.onlineStartDate, + end: currentSession.onlineEndDate, + }); + } + } + + const totalMilliseconds = mergedIntervals.reduce((total, interval) => { + const duration = interval.end.getTime() - interval.start.getTime(); + return total + (duration > 0 ? duration : 0); + }, 0); + + activityTime = Math.round(totalMilliseconds / 1000); + } + + // Rate calculation - use expertRate from answered sessions + const validRates = answeredSessions + .map((session) => session.expertRate) + .filter((rate) => rate !== null && rate !== undefined); + + // Calculate average rate (rounded to 2 decimal places - same as findExpertsByFilter) + const avgRate = validRates.length > 0 + ? Math.round((validRates.reduce((sum, rate) => sum + rate, 0) / validRates.length) * 100) / 100 + : 0; + + // Calculate non-rated sessions count + const nonRatedCount = totalAnswered - validRates.length; + + // Calculate rate details distribution + const rateDetails = { + 1: validRates.filter((rate) => rate === 1).length, + 2: validRates.filter((rate) => rate === 2).length, + 3: validRates.filter((rate) => rate === 3).length, + 4: validRates.filter((rate) => rate === 4).length, + 5: validRates.filter((rate) => rate === 5).length, + nonRated: nonRatedCount, + avg: avgRate, + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: admin._id, + name: admin.name, + family: admin.family, + mobile: admin.mobile, + email: admin.email, + username: admin.username, + role: admin.role, + isActive: admin.isActive, + reports: { + activityTime, + totalSessions, + totalAnswered, + totalNotAnswered, + totalRate: avgRate, + rateDetails, + currentNotActive: 0, + waitingAvgTime:`${waitingAvgTime}`, + }, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async expertAllSessions(adminIdentity) { + try { + //todo + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findAllExperts( + expertUsername: string, + filter: { rate?: string; date?: string; search: string }, + ) { + interface Session { + sessionId: string; + rate: number; + createdAt: [string, string]; + } + + interface Expert { + _id: any; + mobile: string; + username: string; + rate: Session[] | null; + createdAt: Date; + } + + interface ProcessedExpert { + _id: any; + mobile: string; + username: string; + totalSessions: number; + avgRate: number; + first: [string, string] | null; + last: [string, string] | null; + } + + try { + const experts: Expert[] = await this.admin.find({ + role: 'expert', + }); + + // Process filter if provided + const rateFilter = filter?.rate ? Number(filter.rate) : null; + let dateRange: { start: string; end: string } | null = null; + + if (filter?.date) { + const [startDate, endDate] = filter.date.split('-'); + dateRange = { + start: startDate.replace(/\//g, '-'), + end: endDate.replace(/\//g, '-'), + }; + } + + const processedExperts: ProcessedExpert[] = experts.map((expert) => { + const { _id, mobile, username, rate, createdAt } = expert; + + // Filter sessions by date if date filter is provided + let filteredSessions: Session[] = rate ? [...rate] : []; + if (dateRange) { + filteredSessions = filteredSessions.filter((session) => { + const sessionDate = session.createdAt[1]; + return ( + sessionDate >= dateRange!.start && sessionDate <= dateRange!.end + ); + }); + } + + // Calculate totalSessions + const totalSessions: number = filteredSessions.length; + + // Calculate avgRate with proper type handling + let avgRate: number = 0; + if (totalSessions > 0) { + const sum: number = filteredSessions.reduce( + (sum, session) => sum + session.rate, + 0, + ); + avgRate = parseFloat((sum / totalSessions).toFixed(1)); + } + + // Extract first and last dates + const first: [string, string] | null = + totalSessions > 0 ? filteredSessions[0].createdAt : null; + const last: [string, string] | null = + totalSessions > 0 + ? filteredSessions[totalSessions - 1].createdAt + : null; + + return { + _id, + mobile, + username, + totalSessions, + avgRate, + first, + last, + }; + }); + + // Apply rate filter if provided + const filteredResults: ProcessedExpert[] = + rateFilter !== null + ? processedExperts.filter( + (expert) => Math.floor(expert.avgRate) === rateFilter, + ) + : processedExperts; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', filteredResults); + } catch (err: any) { + console.log(err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async findExpertsByFilter( + adminIdentity: any, + filter: { + rate?: string; + date?: string; + search?: string; + activityType?: ActivityType; + }, + ) { + try { + if (filter.rate && (filter.date || filter.search || filter.activityType)) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Rate filter must be used alone', null); + } + if (filter.search && (filter.rate || filter.date || filter.activityType)) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Search filter must be used alone', null); + } + if ((filter.date && !filter.activityType) || (filter.activityType && !filter.date)) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Date filter and activityType must be used together', null); + } + + const pipeline: any[] = []; + const matchStage: any = { role: 'expert' }; + if (filter.search?.trim()) { + const searchRegex = new RegExp(filter.search.trim(), 'i'); + matchStage.$or = [{ name: searchRegex }, { family: searchRegex }, { mobile: searchRegex }]; + } + pipeline.push({ $match: matchStage }); + + // Build session match conditions for the lookup + const sessionMatchConditions: any = { expert: '$$username' }; + + // Add date filter if provided + if (filter.date && filter.activityType) { + const startDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[0].trim())); + const endDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[1].trim())); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + const dateField = filter.activityType === 'startActivity' ? 'createdISO' : 'onlineEndDate'; + sessionMatchConditions[dateField] = { $gte: startDate, $lte: endDate }; + } + + // Lookup to get only the minimal session data we need + pipeline.push({ + $lookup: { + from: 'sessions', + let: { username: '$username' }, + pipeline: [ + { $match: { $expr: { $eq: ['$expert', '$$username'] } } }, + { + $project: { + _id: 0, + createdAt: 1, + connectedToExpert: 1, + expertRate: 1, + adminRateToExpert: 1, + createdISO: 1, + onlineEndDate: 1, + }, + }, + ], + as: 'sessions', + }, + }); + + const experts = await this.admin.aggregate(pipeline); + + // Fetch online status from Redis for all experts + const redisClient = this.redisService.getClient(); + const ONLINE_EXPERTS_KEY = 'onlineExperts'; + let onlineExpertMap = new Map(); + + try { + const onlineExpertsEntries = await redisClient.hgetall(ONLINE_EXPERTS_KEY); + Object.entries(onlineExpertsEntries).forEach(([expertId, expertDataStr]) => { + try { + const expertData = JSON.parse(expertDataStr as string); + // Expert is online if isOnline is true OR has active sessions + const isOnline = expertData.isOnline === true || (expertData.activeSessions > 0); + onlineExpertMap.set(expertId, isOnline); + } catch (err) { + // Skip invalid JSON entries + } + }); + } catch (err) { + console.error('Error fetching online experts from Redis:', err); + // Continue with offline status for all if Redis fails + } + + // Process in Node.js with minimal memory footprint + const processedExperts = experts.map(expert => { + const { sessions, ...expertBase } = expert; + + // Calculate total sessions + const totalSessions = sessions.length; + + // Filter sessions based on date/activity if needed + let relevantSessions = sessions; + if (filter.date && filter.activityType) { + const startDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[0].trim())); + const endDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[1].trim())); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + const dateField = filter.activityType === 'startActivity' ? 'createdISO' : 'onlineEndDate'; + relevantSessions = sessions.filter(s => { + const sessionDate = new Date(s[dateField]); + return sessionDate >= startDate && sessionDate <= endDate; + }); + } + + // Get connected sessions from relevant ones + const connectedSessions = relevantSessions.filter(s => s.connectedToExpert === true); + + // Calculate average rate (rounded to 2 decimal places) + const validRates = connectedSessions + .map(s => s.expertRate) + .filter(r => r !== null && r !== undefined); + const avgRate = validRates.length > 0 + ? Math.round((validRates.reduce((sum, rate) => sum + rate, 0) / validRates.length) * 100) / 100 + : 0; + + // Calculate admin ratings + const adminRates = connectedSessions + .map(s => s.adminRateToExpert) + .filter(r => r !== null && r !== undefined); + + let adminRateResult: 'like' | 'dislike' | 'equal' | null = null; + const likeCount = adminRates.filter(r => r === true).length; + const dislikeCount = adminRates.filter(r => r === false).length; + + if (adminRates.length > 0) { + if (likeCount > dislikeCount) adminRateResult = 'like'; + else if (dislikeCount > likeCount) adminRateResult = 'dislike'; + else adminRateResult = 'equal'; + } + + // Get first and last session dates + const sortedSessions = sessions + .filter(s => s.createdISO) + .sort((a, b) => new Date(a.createdISO).getTime() - new Date(b.createdISO).getTime()); + const firstSession = sortedSessions.length > 0 ? sortedSessions[0].createdISO : null; + const lastSession = sortedSessions.length > 0 ? sortedSessions[sortedSessions.length - 1].createdISO : null; + + // Get online status from Redis (expertId is the _id as string) + const expertId = expertBase._id.toString(); + const isOnline = onlineExpertMap.get(expertId) || false; + + return { + _id: expertBase._id, + mobile: expertBase.mobile, + name: expertBase.name, + family: expertBase.family, + username: expertBase.username, + totalSessions, + avgRate, + adminRate: adminRateResult, + like: likeCount, + dislike: dislikeCount, + firstSession, + lastSession, + isActive: expertBase.isActive, + isOnline, + }; + }); + + let finalResults = processedExperts; + if (filter.rate?.trim()) { + const rateValue = parseInt(filter.rate.trim(), 10); + if (!isNaN(rateValue)) { + finalResults = processedExperts.filter(expert => Math.floor(expert.avgRate) === rateValue); + } + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', finalResults, null); + + } catch (err: any) { + console.error('Error in findExpertsByFilter:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.message || 'Internal Server Error', + null, + null, + ); + } + } +} diff --git a/src/widget/dto/validate-session.dto.ts b/src/widget/dto/validate-session.dto.ts new file mode 100644 index 0000000..2adb5a0 --- /dev/null +++ b/src/widget/dto/validate-session.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class ValidateSessionDto { + @ApiProperty({ + description: 'Widget hash key', + example: 'wgt_h1x2y3z4', + }) + @IsString() + @IsNotEmpty() + hash: string; + + @ApiProperty({ + description: 'Session nonce', + example: '1234567890abcdef', + }) + @IsString() + @IsNotEmpty() + nonce: string; + + @ApiProperty({ + description: 'Session encryption key', + example: '1234567890abcdef1234567890abcdef', + }) + @IsString() + @IsNotEmpty() + sessionKey: string; +} diff --git a/src/widget/dto/widget-script.dto.ts b/src/widget/dto/widget-script.dto.ts new file mode 100644 index 0000000..c2cb34e --- /dev/null +++ b/src/widget/dto/widget-script.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, Matches } from 'class-validator'; + +export class WidgetScriptDto { + @ApiProperty({ + description: 'Hash key for widget authentication', + example: 'wgt_h1x2y3z4', + }) + @IsString() + @IsNotEmpty() + @Matches(/^wgt_[a-zA-Z0-9]{8}$/, { + message: + 'Invalid widget hash format. Must start with wgt_ followed by 8 alphanumeric characters', + }) + hash: string; +} diff --git a/src/widget/widget.controller.ts b/src/widget/widget.controller.ts new file mode 100644 index 0000000..622db2b --- /dev/null +++ b/src/widget/widget.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Query, Header, BadRequestException, Req, Res } from '@nestjs/common'; +import { Public } from 'src/auth/auth.decorator'; +import { WidgetService } from './widget.service'; + +@Controller('widget') +@Public() +export class WidgetController { + constructor(private readonly widgetService: WidgetService) { } + + @Get('script') + @Header('Content-Type', 'application/javascript') + @Header('Access-Control-Allow-Origin', 'https://staging.hdmplus.ir/, https://si24.ir') + @Header('X-Frame-Options', 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir') + @Header( + 'Content-Security-Policy', + "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir", + ) + @Header('X-Content-Type-Options', 'nosniff') + getWidgetScript( + @Query('apiKey') apiKey: string, + @Req() req: Request, + ): string { + if (!apiKey) { + throw new BadRequestException('API key is required'); + } + + const isValidApiKey = + Buffer.from(apiKey).length === Buffer.from('si24samanwebsite').length && + Buffer.from(apiKey).compare(Buffer.from('si24samanwebsite')) === 0; + + if (!isValidApiKey) { + throw new BadRequestException('Invalid API key'); + } + + return this.widgetService.generateWidgetScript(); + } + + @Get('iframe') + async getIframeScript( + @Query('apiKey') apiKey: string, + @Req() req: Request, + @Res() res, + ) { + if (!apiKey || apiKey !== 'si24samanwebsite') { + throw new BadRequestException('API key is invalid or missing'); + } + + res.set({ + 'Content-Type': 'application/javascript', + 'Access-Control-Allow-Origin': 'https://staging.hdmplus.ir/, https://si24.ir', + 'X-Frame-Options': 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir', + 'Content-Security-Policy': "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir", + 'X-Content-Type-Options': 'nosniff', + }); + + const script = this.widgetService.generateWidgetScriptWithoutStyle(); + return res.send(script); + } +} diff --git a/src/widget/widget.module.ts b/src/widget/widget.module.ts new file mode 100644 index 0000000..4d34165 --- /dev/null +++ b/src/widget/widget.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { WidgetController } from './widget.controller'; +import { WidgetService } from './widget.service'; + +@Module({ + imports: [ConfigModule], + controllers: [WidgetController], + providers: [WidgetService], + exports: [WidgetService], +}) +export class WidgetModule {} diff --git a/src/widget/widget.service.ts b/src/widget/widget.service.ts new file mode 100644 index 0000000..0ef8992 --- /dev/null +++ b/src/widget/widget.service.ts @@ -0,0 +1,283 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class WidgetService { + private readonly frontendUrl: string; + constructor(private readonly configService: ConfigService) { + this.frontendUrl = this.configService.get('FRONTEND_URL') || 'https://chatbot.si24.ir'; + // Khahesh mikonam + // 1. az env estefadeh konid + // 2. agar chizi hardcode hast hamahang konid + // -------- az env estefade shude --------- + // c------ chiziam hardcode nist ---- + + } + + generateIframe(): string { + return ` + (function () { + if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return; + + const iframe = document.createElement('iframe'); + iframe.id = 'saman-widget-iframe'; + iframe.src = '${this.frontendUrl}'; + iframe.style.position = 'fixed'; + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.width = '100vw'; + iframe.style.height = '100vh'; + iframe.style.border = 'none'; + iframe.style.zIndex = '2147483646'; + iframe.style.background = 'white'; + iframe.allow = 'microphone; camera'; + iframe.allowFullscreen = true; + iframe.loading = 'lazy'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups'; + + document.body.appendChild(iframe); + window.samanChatbotLoaded = true; + })(); + `; + } + generateWidgetScript(): string { + return ` + (function () { + if (window.samanChatbotLoaded) return; + window.samanChatbotLoaded = true; + + const style = document.createElement('style'); + style.textContent = \` + #saman-widget-btn { + position: fixed; + bottom: 16px; + right: 16px; + width: 45px; + height: 45px; + border-radius: 50%; + border: none; + background: linear-gradient(135deg,#0062ff,#007bff); + box-shadow: 0 4px 15px rgba(0,0,0,0.2); + z-index: 2147483647; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: width 0.3s ease, height 0.3s ease; + } + #saman-widget-btn img { width: 22px; height: 22px; } + #saman-widget-iframe { + position: fixed; + bottom: 80px; + right: 16px; + width: 380px; + height: 710px; + border: none; + border-radius: 12px; + display: none; + z-index: 2147483646; + box-shadow: 0 10px 40px rgba(0,0,0,0.2); + } + #saman-widget-iframe.visible { display: block; } + @media (max-width: 768px) { + #saman-widget-iframe { + top: 0; left: 0; right: 0; bottom: 0; + width: 100vw; height: 100vh; + border-radius: 0; + } + #saman-close-btn { + position: fixed; + top: 12px; left: 12px; + width: 44px; height: 44px; + border-radius: 50%; + background: rgba(0,0,0,0.6); + color: #fff; + border: none; + display: flex; + align-items: center; + justify-content: center; + z-index: 2147483647; + font-size: 20px; + } + } + \`; + document.head.appendChild(style); + + const btn = document.createElement('button'); + btn.id = 'saman-widget-btn'; + btn.innerHTML = 'chat'; + document.body.appendChild(btn); + + const iframe = document.createElement('iframe'); + iframe.id = 'saman-widget-iframe'; + iframe.src = '${this.frontendUrl}'; + iframe.allow = 'microphone; camera; storage-access-api'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation'; + iframe.setAttribute('loading', 'lazy'); + iframe.setAttribute('credentialless', 'false'); + iframe.setAttribute('allow', 'storage-access-api *'); + document.body.appendChild(iframe); + + let closeBtn; + + function openWidget() { + iframe.classList.add('visible'); + if (window.innerWidth < 768) { + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.borderRadius = '0'; + + closeBtn = document.createElement('button'); + closeBtn.id = 'saman-close-btn'; + closeBtn.innerHTML = '✕'; + document.body.appendChild(closeBtn); + closeBtn.onclick = closeWidget; + + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + btn.style.setProperty('visibility', 'hidden', 'important'); + btn.style.setProperty('opacity', '0', 'important'); + btn.style.setProperty('pointer-events', 'none', 'important'); + + } + + setTimeout(() => { + if (iframe.contentWindow && + 'requestStorageAccess' in document && + typeof document.requestStorageAccess === 'function') { + iframe.contentWindow.postMessage({ + type: 'REQUEST_STORAGE_ACCESS' + }, '${this.frontendUrl}'); + } + }, 1000); + } + + function closeWidget() { + iframe.classList.remove('visible'); + if (closeBtn) { + closeBtn.remove(); + closeBtn = null; + } + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + + // Show the widget button again when closing + btn.style.setProperty('visibility', 'visible', 'important'); + btn.style.setProperty('opacity', '1', 'important'); + btn.style.setProperty('pointer-events', 'auto', 'important'); +} + + btn.addEventListener('click', function () { + if (iframe.classList.contains('visible')) closeWidget(); + else openWidget(); + }); + + const allowedOrigins = ['https://chatbot.si24.ir', 'https://staging.hdmplus.ir']; + + window.addEventListener('message', function(event) { + if (!allowedOrigins.includes(event.origin)) return; + + if (event.data.type === 'REQUEST_DATA') { + iframe.contentWindow.postMessage({ + type: 'RESPONSE_DATA', + payload: event.data.payload + }, event.origin); + } + + if (event.data.type === 'REDIRECT_REQUEST') { + window.top.location.href = event.data.url; + } + + if (event.data.type === 'REQUEST_STORAGE_ACCESS' && + 'requestStorageAccess' in document && + typeof document.requestStorageAccess === 'function') { + document.requestStorageAccess().then(() => { + iframe.contentWindow.postMessage({ + type: 'STORAGE_ACCESS_GRANTED' + }, event.origin); + }).catch(() => { + iframe.contentWindow.postMessage({ + type: 'STORAGE_ACCESS_DENIED' + }, event.origin); + }); + } + }); + + iframe.addEventListener('load', function() { + setTimeout(() => { + iframe.contentWindow.postMessage({ + type: 'IFRAME_LOADED' + }, '${this.frontendUrl}'); + }, 500); + }); + })(); +`; + } + generateWidgetScriptWithoutStyle(): string { + return ` + (function() { + if (window.samanChatbotLoaded) return; + window.samanChatbotLoaded = true; + + const btn = document.createElement('button'); + btn.style.cssText = "position:fixed;bottom:16px;right:16px;width:45px;height:45px;border-radius:50%;border:none;background:linear-gradient(135deg,#0062ff,#007bff);cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2147483647;transition:width 0.3s ease,height 0.3s ease"; + btn.innerHTML = ''; + document.body.appendChild(btn); + + const iframe = document.createElement('iframe'); + iframe.src = '${this.frontendUrl}'; + iframe.allow = 'microphone;camera'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups'; + iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)"; + document.body.appendChild(iframe); + + let closeBtn; + + function openWidget() { + iframe.style.display = 'block'; + if (window.innerWidth < 768) { + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.borderRadius = '0'; + + closeBtn = document.createElement('button'); + closeBtn.innerHTML = '✕'; + closeBtn.style.cssText = "position:fixed;top:12px;left:12px;width:44px;height:44px;border-radius:50%;background:rgba(0,0,0,0.6);color:#fff;border:none;display:flex;align-items:center;justify-content:center;z-index:2147483647;font-size:20px"; + closeBtn.onclick = closeWidget; + document.body.appendChild(closeBtn); + + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + + btn.style.setProperty('visibility', 'hidden', 'important'); + btn.style.setProperty('opacity', '0', 'important'); + btn.style.setProperty('pointer-events', 'none', 'important'); + } + } + + function closeWidget() { + iframe.style.display = 'none'; + if (closeBtn) { closeBtn.remove(); closeBtn = null; } + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + btn.style.setProperty('visibility', 'visible', 'important'); + btn.style.setProperty('opacity', '1', 'important'); + btn.style.setProperty('pointer-events', 'auto', 'important'); + } + + btn.addEventListener('click', function() { + if (iframe.style.display === 'block') closeWidget(); + else openWidget(); + }); + })(); + `; + } + + + + getScript(): string { + return ``; + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..f6c7338 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,41 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +module.exports = { + entry: { + main: './src/index.tsx', + widget: './src/widget/widget-entry.tsx' + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].js', + publicPath: '/' + }, + module: { + rules: [ + { + test: /\.(ts|tsx)$/, + use: 'ts-loader', + exclude: /node_modules/ + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'] + } + ] + }, + resolve: { + extensions: ['.tsx', '.ts', '.js'] + }, + plugins: [ + new HtmlWebpackPlugin({ + template: './public/index.html', + chunks: ['main'] + }), + new HtmlWebpackPlugin({ + filename: 'widget.html', + template: './public/widget.html', + chunks: ['widget'] + }) + ] +}; \ No newline at end of file