forked from Chatbot/v3-api
first commit v3 initialiazed a temporary repository for darmanet client
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose.yml
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
*.log
|
||||
*.env
|
||||
*.test.js
|
||||
test/
|
||||
dist/
|
||||
|
||||
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
@@ -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',
|
||||
},
|
||||
};
|
||||
153
.gitignore
vendored
Normal file
153
.gitignore
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
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
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
# .env
|
||||
# .env.development.local
|
||||
# .env.test.local
|
||||
# .env.production.local
|
||||
# .env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*.csv
|
||||
|
||||
/uploads
|
||||
|
||||
/csv_files
|
||||
|
||||
*.md
|
||||
|
||||
ERROR_EVENTS.ts
|
||||
|
||||
FRONTEND_ERROR_HANDLING_EXAMPLE.ts
|
||||
|
||||
*.html
|
||||
|
||||
*.json
|
||||
README.md
|
||||
|
||||
/scripts
|
||||
/public
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
40
Dockerfile_old
Normal file
40
Dockerfile_old
Normal file
@@ -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"]
|
||||
|
||||
55
Dockerfile_saman
Normal file
55
Dockerfile_saman
Normal file
@@ -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"]
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"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.
|
||||
12
build_n_deploy.sh
Normal file
12
build_n_deploy.sh
Normal file
@@ -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=.
|
||||
|
||||
51
docker-compose.yml
Normal file
51
docker-compose.yml
Normal file
@@ -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
|
||||
59
migration.js
Normal file
59
migration.js
Normal file
@@ -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();
|
||||
201
src/acl/acl.controller.ts
Normal file
201
src/acl/acl.controller.ts
Normal file
@@ -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<AuditLogModel>,
|
||||
) {}
|
||||
|
||||
@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 };
|
||||
}
|
||||
}
|
||||
12
src/acl/acl.module.ts
Normal file
12
src/acl/acl.module.ts
Normal file
@@ -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 {}
|
||||
456
src/acl/acl.service.ts
Normal file
456
src/acl/acl.service.ts
Normal file
@@ -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<AdminModel>,
|
||||
@InjectModel(StaffRoleModel.name)
|
||||
private readonly staffRoleModel: Model<StaffRoleModel>,
|
||||
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<string>('OWNER_EMAIL') || process.env.OWNER_EMAIL;
|
||||
const password =
|
||||
this.configService.get<string>('OWNER_PASSWORD') ||
|
||||
process.env.OWNER_PASSWORD;
|
||||
const mobile =
|
||||
this.configService.get<string>('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 };
|
||||
}
|
||||
}
|
||||
8
src/acl/dto/change-staff-role.dto.ts
Normal file
8
src/acl/dto/change-staff-role.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class ChangeStaffRoleDto {
|
||||
@ApiProperty({ example: 'supervisor' })
|
||||
@IsString()
|
||||
role: string;
|
||||
}
|
||||
19
src/acl/dto/create-custom-role.dto.ts
Normal file
19
src/acl/dto/create-custom-role.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
43
src/acl/dto/create-staff.dto.ts
Normal file
43
src/acl/dto/create-staff.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
src/acl/dto/set-staff-active.dto.ts
Normal file
8
src/acl/dto/set-staff-active.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class SetStaffActiveDto {
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
isActive: boolean;
|
||||
}
|
||||
14
src/acl/dto/update-role-permissions.dto.ts
Normal file
14
src/acl/dto/update-role-permissions.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
16
src/acl/dto/update-user-overrides.dto.ts
Normal file
16
src/acl/dto/update-user-overrides.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
100
src/acl/permissions.service.ts
Normal file
100
src/acl/permissions.service.ts
Normal file
@@ -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<AdminModel>,
|
||||
@InjectModel(StaffRoleModel.name)
|
||||
private readonly staffRoleModel: Model<StaffRoleModel>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* effective = (role.permissions ∪ grants) − denies
|
||||
* Owner always has every assignable permission.
|
||||
*/
|
||||
computeEffective(
|
||||
roleName: string,
|
||||
rolePermissions: string[],
|
||||
grants: string[] = [],
|
||||
denies: string[] = [],
|
||||
): Set<Permission> {
|
||||
if (isOwnerRole(roleName)) {
|
||||
return new Set(ALL_ASSIGNABLE_PERMISSIONS);
|
||||
}
|
||||
|
||||
const effective = new Set<string>([
|
||||
...rolePermissions,
|
||||
...(grants || []),
|
||||
]);
|
||||
for (const deny of denies || []) {
|
||||
effective.delete(deny);
|
||||
}
|
||||
return effective as Set<Permission>;
|
||||
}
|
||||
|
||||
async getEffectivePermissionsForAdminId(
|
||||
adminId: string,
|
||||
): Promise<Set<Permission>> {
|
||||
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<Set<Permission>> {
|
||||
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<boolean> {
|
||||
const effective = await this.getEffectivePermissionsForAdminId(adminId);
|
||||
const needed = Array.isArray(required) ? required : [required];
|
||||
return needed.some((p) => effective.has(p));
|
||||
}
|
||||
|
||||
hasAny(effective: Set<Permission>, required: Permission[]): boolean {
|
||||
return required.some((p) => effective.has(p));
|
||||
}
|
||||
}
|
||||
8
src/ai-service/ai-service.module.ts
Normal file
8
src/ai-service/ai-service.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiServiceService } from './ai-service.service';
|
||||
|
||||
@Module({
|
||||
providers: [AiServiceService],
|
||||
exports: [AiServiceService],
|
||||
})
|
||||
export class AiServiceModule {}
|
||||
534
src/ai-service/ai-service.service.ts
Normal file
534
src/ai-service/ai-service.service.ts
Normal file
@@ -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<string, string> = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, string>),
|
||||
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<string, string>),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
250
src/ai-v2/ai-v2-admin.controller.ts
Normal file
250
src/ai-v2/ai-v2-admin.controller.ts
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
48
src/ai-v2/ai-v2-ask.mapper.spec.ts
Normal file
48
src/ai-v2/ai-v2-ask.mapper.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
118
src/ai-v2/ai-v2-ask.mapper.ts
Normal file
118
src/ai-v2/ai-v2-ask.mapper.ts
Normal file
@@ -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<AiV2RunResult, 'status'>): 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);
|
||||
}
|
||||
71
src/ai-v2/ai-v2-threads.controller.ts
Normal file
71
src/ai-v2/ai-v2-threads.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
407
src/ai-v2/ai-v2.client.ts
Normal file
407
src/ai-v2/ai-v2.client.ts
Normal file
@@ -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<string, unknown> } | 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<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey()}`,
|
||||
accept: 'application/json',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
private qs(params?: object): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
try {
|
||||
const response = await axios.request<T>({
|
||||
timeout: this.timeoutMs(),
|
||||
maxBodyLength: Infinity,
|
||||
...config,
|
||||
baseURL: this.baseUrl(),
|
||||
headers: this.headers(config.headers as Record<string, string>),
|
||||
});
|
||||
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<string, string>,
|
||||
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<AxiosResponse<Readable>> {
|
||||
try {
|
||||
const response = await axios.request<Readable>({
|
||||
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<Readable>): Promise<never> {
|
||||
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<AiV2RunResult> {
|
||||
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<AiV2RunResult> {
|
||||
return this.consumeRun(threadId, body);
|
||||
}
|
||||
|
||||
async pipeRun(
|
||||
threadId: string,
|
||||
body: { message: string; user_id: string },
|
||||
dest: NodeJS.WritableStream,
|
||||
onClientClose?: (abort: () => void) => void,
|
||||
): Promise<void> {
|
||||
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<void>((resolve, reject) => {
|
||||
upstream.on('error', reject);
|
||||
dest.on('error', reject);
|
||||
dest.on('close', abort);
|
||||
upstream.on('end', () => resolve());
|
||||
upstream.pipe(dest, { end: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
11
src/ai-v2/ai-v2.exception.ts
Normal file
11
src/ai-v2/ai-v2.exception.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/ai-v2/ai-v2.module.ts
Normal file
14
src/ai-v2/ai-v2.module.ts
Normal file
@@ -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 {}
|
||||
326
src/ai-v2/ai-v2.service.ts
Normal file
326
src/ai-v2/ai-v2.service.ts
Normal file
@@ -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<SessionModel>,
|
||||
@InjectModel(ReactsModel.name)
|
||||
private readonly reacts: Model<ReactsModel>,
|
||||
) {}
|
||||
|
||||
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<string, unknown> = {};
|
||||
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<T>(data: T, status: HttpStatus = HttpStatus.OK, message = 'SUCCESS') {
|
||||
return new BaseResponseDTO(status, message, data);
|
||||
}
|
||||
|
||||
created<T>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/ai-v2/dto/common-query.dto.ts
Normal file
30
src/ai-v2/dto/common-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
53
src/ai-v2/dto/domains.dto.ts
Normal file
53
src/ai-v2/dto/domains.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpdateDomainDto {
|
||||
@ApiProperty({ example: 'Frequently asked questions' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
display_name: string;
|
||||
}
|
||||
17
src/ai-v2/dto/files.dto.ts
Normal file
17
src/ai-v2/dto/files.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
116
src/ai-v2/dto/points.dto.ts
Normal file
116
src/ai-v2/dto/points.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
@ApiProperty({ description: 'Expected current version (optimistic lock).' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
version: number;
|
||||
}
|
||||
31
src/ai-v2/dto/retrieval.dto.ts
Normal file
31
src/ai-v2/dto/retrieval.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
59
src/ai-v2/dto/threads.dto.ts
Normal file
59
src/ai-v2/dto/threads.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
27
src/ai-v2/sse.spec.ts
Normal file
27
src/ai-v2/sse.spec.ts
Normal file
@@ -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']);
|
||||
});
|
||||
});
|
||||
73
src/ai-v2/sse.ts
Normal file
73
src/ai-v2/sse.ts
Normal file
@@ -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<string> {
|
||||
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<SseEvent> {
|
||||
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<SseEvent[]> {
|
||||
const events: SseEvent[] = [];
|
||||
for await (const event of iterateSseEvents(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
91
src/api/admin/admin.controller.ts
Normal file
91
src/api/admin/admin.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
22
src/api/admin/admin.module.ts
Normal file
22
src/api/admin/admin.module.ts
Normal file
@@ -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 {}
|
||||
333
src/api/admin/admin.service.ts
Normal file
333
src/api/admin/admin.service.ts
Normal file
@@ -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<AdminModel>,
|
||||
@InjectModel(SessionModel.name)
|
||||
private readonly sessionModel: Model<SessionModel>,
|
||||
@InjectModel(UserModel.name)
|
||||
private readonly userModel: Model<UserModel>,
|
||||
@InjectModel(ReassignedLogsModel.name)
|
||||
private readonly reassignedLogsModel: Model<ReassignedLogsModel>,
|
||||
@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<AdminModel>): Promise<AdminDocument> {
|
||||
return await this.adminModel.findOne(query);
|
||||
}
|
||||
|
||||
async getProfile(adminIdentity): Promise<any> {
|
||||
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<AdminModel | null>({ // 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<BaseResponseDTO> {
|
||||
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<BaseResponseDTO> {
|
||||
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<string>();
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/api/admin/dto/create-admin.dto.ts
Normal file
15
src/api/admin/dto/create-admin.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
46
src/api/admin/dto/create-new-expert.dto.ts
Normal file
46
src/api/admin/dto/create-new-expert.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
18
src/api/admin/dto/edit-profile.dto.ts
Normal file
18
src/api/admin/dto/edit-profile.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
src/api/admin/dto/update-admin.dto.ts
Normal file
4
src/api/admin/dto/update-admin.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateAdminDto } from './create-admin.dto';
|
||||
|
||||
export class UpdateAdminDto extends PartialType(CreateAdminDto) {}
|
||||
76
src/api/user/dto/user.dto.ts
Normal file
76
src/api/user/dto/user.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
116
src/api/user/user.controller.ts
Normal file
116
src/api/user/user.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/api/user/user.module.ts
Normal file
14
src/api/user/user.module.ts
Normal file
@@ -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 {}
|
||||
935
src/api/user/user.service.ts
Normal file
935
src/api/user/user.service.ts
Normal file
@@ -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<UserModel>,
|
||||
@InjectModel(SessionModel.name)
|
||||
private readonly session: Model<SessionModel>,
|
||||
@InjectModel(AdminModel.name) private readonly admin: Model<AdminModel>,
|
||||
@InjectModel(DictionariesModel.name)
|
||||
private readonly dictionaries: Model<DictionariesModel>,
|
||||
@InjectModel(ReactsModel.name)
|
||||
private readonly reacts: Model<ReactsModel>,
|
||||
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<any> {
|
||||
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<UserModel>): Promise<UserModel> {
|
||||
return await this.user.findOne(filter);
|
||||
}
|
||||
|
||||
async updateOneUser(
|
||||
filter: FilterQuery<UserModel>,
|
||||
update: UpdateQuery<UserModel>,
|
||||
): Promise<any> {
|
||||
return await this.user.updateOne(filter, update).lean();
|
||||
}
|
||||
|
||||
// public async toggleExpertActions(
|
||||
// userId: string,
|
||||
// sessionId: string,
|
||||
// field: string,
|
||||
// ): Promise<void> {
|
||||
// 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/app.module.ts
Normal file
76
src/app.module.ts
Normal file
@@ -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 {}
|
||||
6
src/audio/audio-normalization.errors.ts
Normal file
6
src/audio/audio-normalization.errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class AudioNormalizationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AudioNormalizationError';
|
||||
}
|
||||
}
|
||||
216
src/audio/audio-normalization.service.ts
Normal file
216
src/audio/audio-normalization.service.ts
Normal file
@@ -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<string>('AUDIO_FFMPEG_PATH')?.trim() || 'ffmpeg';
|
||||
this.ffprobePath = this.config.get<string>('AUDIO_FFPROBE_PATH')?.trim() || 'ffprobe';
|
||||
const raw = this.config.get<string>('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<number | undefined> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/audio/audio.module.ts
Normal file
8
src/audio/audio.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AudioNormalizationService } from './audio-normalization.service';
|
||||
|
||||
@Module({
|
||||
providers: [AudioNormalizationService],
|
||||
exports: [AudioNormalizationService],
|
||||
})
|
||||
export class AudioModule {}
|
||||
147
src/auth/auth.controller.ts
Normal file
147
src/auth/auth.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
4
src/auth/auth.decorator.ts
Normal file
4
src/auth/auth.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
62
src/auth/auth.guard.ts
Normal file
62
src/auth/auth.guard.ts
Normal file
@@ -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<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(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;
|
||||
}
|
||||
}
|
||||
32
src/auth/auth.module.ts
Normal file
32
src/auth/auth.module.ts
Normal file
@@ -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 {}
|
||||
1287
src/auth/auth.service.ts
Normal file
1287
src/auth/auth.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
274
src/auth/guards/admin.guard.ts
Normal file
274
src/auth/guards/admin.guard.ts
Normal file
@@ -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<typeof rateLimit>;
|
||||
|
||||
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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
// Check if the Admin rate limit should be skipped for this handler/class
|
||||
const skipAdminRateLimit = this.reflector.getAllAndOverride<boolean>(
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
38
src/auth/guards/api-key-auth.guard.ts
Normal file
38
src/auth/guards/api-key-auth.guard.ts
Normal file
@@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
48
src/auth/guards/ip-rate-limiter.guard.ts
Normal file
48
src/auth/guards/ip-rate-limiter.guard.ts
Normal file
@@ -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<typeof rateLimit>;
|
||||
|
||||
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<boolean> {
|
||||
const httpContext = context.switchToHttp();
|
||||
const req = httpContext.getRequest<Request>();
|
||||
const res = httpContext.getResponse<Response>();
|
||||
return new Promise((resolve) => {
|
||||
this.limiter(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
return resolve(false);
|
||||
}
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
17
src/auth/local.strategy.ts
Normal file
17
src/auth/local.strategy.ts
Normal file
@@ -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<any> {
|
||||
// const user = await this.authService.validateUser(username, password);
|
||||
|
||||
// return user;
|
||||
// }
|
||||
}
|
||||
14
src/auth/models/identity.model.ts
Normal file
14
src/auth/models/identity.model.ts
Normal file
@@ -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;
|
||||
// }
|
||||
// }
|
||||
156
src/business-hours/business-hours.controller.ts
Normal file
156
src/business-hours/business-hours.controller.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/business-hours/business-hours.module.ts
Normal file
24
src/business-hours/business-hours.module.ts
Normal file
@@ -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 {}
|
||||
|
||||
422
src/business-hours/business-hours.service.ts
Normal file
422
src/business-hours/business-hours.service.ts
Normal file
@@ -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<BusinessHoursModel>,
|
||||
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<BusinessHoursConfig | null> {
|
||||
// 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<BusinessHoursStatus> {
|
||||
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<Date | null> {
|
||||
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<BusinessHoursModel> {
|
||||
// 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<BusinessHoursModel> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, number> = {
|
||||
'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<string, number> = {
|
||||
'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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
190
src/business-hours/dto/create-business-hours.dto.ts
Normal file
190
src/business-hours/dto/create-business-hours.dto.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
44
src/business-hours/guards/business-hours.guard.ts
Normal file
44
src/business-hours/guards/business-hours.guard.ts
Normal file
@@ -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<boolean> {
|
||||
const client = context.switchToWs().getClient<Socket>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
100
src/business-hours/status.controller.ts
Normal file
100
src/business-hours/status.controller.ts
Normal file
@@ -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<string, string> = {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
src/categories/categories.controller.ts
Normal file
26
src/categories/categories.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
11
src/categories/categories.module.ts
Normal file
11
src/categories/categories.module.ts
Normal file
@@ -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 {}
|
||||
43
src/categories/categories.service.ts
Normal file
43
src/categories/categories.service.ts
Normal file
@@ -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<CategoriesModel>,
|
||||
) {}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/categories/dto/create-category.dto.ts
Normal file
27
src/categories/dto/create-category.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
src/categories/dto/update-category.dto.ts
Normal file
4
src/categories/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCategoryDto } from './create-category.dto';
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
27
src/chat-attachments/attachment-session-expert.guard.ts
Normal file
27
src/chat-attachments/attachment-session-expert.guard.ts
Normal file
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
27
src/chat-attachments/attachment-session-user.guard.ts
Normal file
27
src/chat-attachments/attachment-session-user.guard.ts
Normal file
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
202
src/chat-attachments/chat-attachments.controller.ts
Normal file
202
src/chat-attachments/chat-attachments.controller.ts
Normal file
@@ -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<string, unknown>)?.fileType,
|
||||
);
|
||||
return this.attachments.uploadAttachment({
|
||||
sessionId,
|
||||
file,
|
||||
uploaderId: String(user._id),
|
||||
uploaderRole: 'User',
|
||||
fileType,
|
||||
durationSec: this.parseDurationSec(
|
||||
(req.body as Record<string, unknown>)?.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<string, unknown>)?.fileType,
|
||||
);
|
||||
return this.attachments.uploadAttachment({
|
||||
sessionId,
|
||||
file,
|
||||
uploaderId: String(admin._id),
|
||||
uploaderRole: 'Expert',
|
||||
fileType,
|
||||
durationSec: this.parseDurationSec(
|
||||
(req.body as Record<string, unknown>)?.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<StreamableFile> {
|
||||
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<StreamableFile> {
|
||||
const { stream, contentType } =
|
||||
await this.attachments.streamPrivateAttachment({
|
||||
sessionId,
|
||||
storageKey,
|
||||
});
|
||||
return new StreamableFile(stream, {
|
||||
type: contentType,
|
||||
disposition: `inline; filename="attachment"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/chat-attachments/chat-attachments.module.ts
Normal file
27
src/chat-attachments/chat-attachments.module.ts
Normal file
@@ -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 {}
|
||||
330
src/chat-attachments/chat-attachments.service.ts
Normal file
330
src/chat-attachments/chat-attachments.service.ts
Normal file
@@ -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<ChatAttachmentFileType>(['voice']);
|
||||
const NON_VOICE_TYPES = new Set<ChatAttachmentFileType>(['image', 'document']);
|
||||
|
||||
@Injectable()
|
||||
export class ChatAttachmentsService {
|
||||
constructor(
|
||||
private readonly storage: StorageService,
|
||||
private readonly audioNormalizer: AudioNormalizationService,
|
||||
@InjectModel(ChatMessageAttachmentModel.name)
|
||||
private readonly attachments: Model<ChatMessageAttachmentModel>,
|
||||
) {}
|
||||
|
||||
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<BaseResponseDTO> {
|
||||
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<BaseResponseDTO> {
|
||||
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<BaseResponseDTO> {
|
||||
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<BaseResponseDTO> {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
127
src/cli.ts
Normal file
127
src/cli.ts
Normal file
@@ -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();
|
||||
})();
|
||||
58
src/client-management/client-management.controller.ts
Normal file
58
src/client-management/client-management.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
12
src/client-management/client-management.module.ts
Normal file
12
src/client-management/client-management.module.ts
Normal file
@@ -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 {}
|
||||
54
src/client-management/client-management.service.ts
Normal file
54
src/client-management/client-management.service.ts
Normal file
@@ -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<ClientModel>,
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/client-management/dto/clients.dto.ts
Normal file
19
src/client-management/dto/clients.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
20
src/common/decorators/Identity.decorator.ts
Normal file
20
src/common/decorators/Identity.decorator.ts
Normal file
@@ -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;
|
||||
},
|
||||
);
|
||||
8
src/common/decorators/permission.decorator.ts
Normal file
8
src/common/decorators/permission.decorator.ts
Normal file
@@ -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);
|
||||
4
src/common/decorators/role.decorator.ts
Normal file
4
src/common/decorators/role.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
4
src/common/decorators/skip-admin-rate-limit.decorator.ts
Normal file
4
src/common/decorators/skip-admin-rate-limit.decorator.ts
Normal file
@@ -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);
|
||||
77
src/common/dto/base-response.dto.ts
Normal file
77
src/common/dto/base-response.dto.ts
Normal file
@@ -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<T>(mapper: (item: any) => T): T[] {
|
||||
if (Array.isArray(this.data)) {
|
||||
return this.data.map(mapper);
|
||||
}
|
||||
return { ...this.data };
|
||||
}
|
||||
}
|
||||
8
src/common/dto/forgetPassword.dto.ts
Normal file
8
src/common/dto/forgetPassword.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class ForgetPasswordDTO {
|
||||
@IsEmail()
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
}
|
||||
70
src/common/dto/login.dto.ts
Normal file
70
src/common/dto/login.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
12
src/common/dto/resetPassword.dto.ts
Normal file
12
src/common/dto/resetPassword.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
30
src/common/filters/all-exceptions.filter.ts
Normal file
30
src/common/filters/all-exceptions.filter.ts
Normal file
@@ -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<T> 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<Request>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/common/filters/ws-all-exceptions.filter.ts
Normal file
54
src/common/filters/ws-all-exceptions.filter.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
66
src/common/helpers/redis.service.ts
Normal file
66
src/common/helpers/redis.service.ts
Normal file
@@ -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<void> {
|
||||
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<T = any>(key: string): Promise<T | null> {
|
||||
const result = await this.redis.get(key);
|
||||
return result ? JSON.parse(result) : null;
|
||||
}
|
||||
|
||||
//* Deletes a key
|
||||
async del(key: string): Promise<void> {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
//* Push a value to a list (for queue operations)
|
||||
async enqueue(queueName: string, value: any): Promise<void> {
|
||||
await this.redis.rpush(queueName, JSON.stringify(value));
|
||||
}
|
||||
|
||||
//* Pops a value from the left of a list (FIFO queue)
|
||||
async dequeue<T = any>(queueName: string): Promise<T | null> {
|
||||
const result = await this.redis.lpop(queueName);
|
||||
return result ? JSON.parse(result) : null;
|
||||
}
|
||||
|
||||
//* Returns the queue length
|
||||
async queueLength(queueName: string): Promise<number> {
|
||||
return await this.redis.llen(queueName);
|
||||
}
|
||||
|
||||
// * Gets all values from a queue
|
||||
async getAllFromQueue<T = any>(queueName: string): Promise<T[]> {
|
||||
const items = await this.redis.lrange(queueName, 0, -1);
|
||||
return items.map((x) => JSON.parse(x));
|
||||
}
|
||||
async blacklistToken(token: string, ttlInSeconds: number): Promise<void> {
|
||||
const ttlInMs = ttlInSeconds * 1000;
|
||||
await this.redis.set(`blacklist:${token}`, 'revoked', 'PX', ttlInMs);
|
||||
}
|
||||
|
||||
async isTokenBlacklisted(token: string): Promise<boolean> {
|
||||
const result = await this.redis.get(`blacklist:${token}`);
|
||||
return result === 'revoked';
|
||||
}
|
||||
}
|
||||
204
src/common/helpers/structured-logger.service.ts
Normal file
204
src/common/helpers/structured-logger.service.ts
Normal file
@@ -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, any>): string {
|
||||
const categoryTag = `[${category}]`;
|
||||
const contextStr = context ? ` ${JSON.stringify(context)}` : '';
|
||||
return `${categoryTag} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
*/
|
||||
log(category: LogCategory, message: string, context?: Record<string, any>): void;
|
||||
log(message: string, context?: Record<string, any>): void;
|
||||
log(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, context?: Record<string, any>): 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<string, any>, error?: any): void;
|
||||
warn(message: string, context?: Record<string, any>): void;
|
||||
warn(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, contextOrError?: Record<string, any> | 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<string, any> = {};
|
||||
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<string, any>;
|
||||
}
|
||||
}
|
||||
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<string, any>): void;
|
||||
error(message: string, error?: any, context?: Record<string, any>): void;
|
||||
error(categoryOrMessage: LogCategory | string, messageOrError?: string | any, errorOrContext?: any, context?: Record<string, any>): 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<string, any>): void;
|
||||
debug(message: string, context?: Record<string, any>): void;
|
||||
debug(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, context?: Record<string, any>): 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<string, any>): void;
|
||||
verbose(message: string, context?: Record<string, any>): void;
|
||||
verbose(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record<string, any>, context?: Record<string, any>): 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
src/common/helpers/versioning.helper.ts
Normal file
46
src/common/helpers/versioning.helper.ts
Normal file
@@ -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;
|
||||
}
|
||||
18
src/common/middlewares/security-headers.middleware.ts
Normal file
18
src/common/middlewares/security-headers.middleware.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
53
src/common/middlewares/swagger-auth.middleware.ts
Normal file
53
src/common/middlewares/swagger-auth.middleware.ts
Normal file
@@ -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<string>('SWAGGER_USERNAME');
|
||||
const swaggerPassword = this.configService.get<string>('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');
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user