forked from Chatbot/v3-api
Merge pull request 'main' (#1) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#1
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',
|
||||
},
|
||||
};
|
||||
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -7,6 +6,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
docker-compose-*.yml
|
||||
Dockerfile
|
||||
*.env
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
@@ -74,11 +76,11 @@ web_modules/
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
# .env
|
||||
# .env.development.local
|
||||
# .env.test.local
|
||||
# .env.production.local
|
||||
# .env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
@@ -105,12 +107,6 @@ dist
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
@@ -136,3 +132,22 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*.csv
|
||||
|
||||
/uploads
|
||||
|
||||
/csv_files
|
||||
|
||||
*.md
|
||||
|
||||
ERROR_EVENTS.ts
|
||||
|
||||
FRONTEND_ERROR_HANDLING_EXAMPLE.ts
|
||||
|
||||
*.html
|
||||
|
||||
*.json
|
||||
README.md
|
||||
|
||||
/scripts
|
||||
/public
|
||||
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"]
|
||||
203
LICENSE
203
LICENSE
@@ -1,10 +1,201 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
|
||||
1. Definitions.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
For more information, please refer to <http://unlicense.org/>
|
||||
"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