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