feat: initial commit

This commit is contained in:
Mohamed Marrouchi
2024-09-10 10:50:11 +01:00
commit 30e5766487
879 changed files with 122820 additions and 0 deletions

13
api/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
dist
node_modules
.dockerignore
.env
.eslintrc.js
.git
.gitignore
.husky
.prettierrc
coverage*
Makefile
README.md
test

74
api/.eslintrc.js Normal file
View File

@@ -0,0 +1,74 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin', 'import'],
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/lines-between-class-members': [
1,
{
enforce: [
{ blankLine: 'always', prev: '*', next: 'field' },
{ blankLine: 'always', prev: 'field', next: '*' },
{ blankLine: 'always', prev: 'method', next: 'method' },
],
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/padding-line-between-statements': [
2,
{ blankLine: 'always', prev: '*', next: 'export' },
{ blankLine: 'always', prev: '*', next: 'function' },
],
'lines-between-class-members': 'off',
'no-console': 2,
'no-duplicate-imports': 2,
'object-shorthand': 1,
'import/order': [
'error',
{
groups: [
'builtin', // Built-in imports (come from NodeJS native) go first
'external', // <- External imports
'unknown', // <- unknown
['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together
'index', // <- index imports
'internal', // <- Absolute imports
],
'newlines-between': 'always',
alphabetize: {
/* sort in ascending order. Options: ["ignore", "asc", "desc"] */
order: 'asc',
/* ignore case. Options: [true, false] */
caseInsensitive: true,
},
},
],
},
};

6
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
uploads/
documentation/
avatars

5
api/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"importOrderSeparation": true
}

20
api/.swcrc Normal file
View File

@@ -0,0 +1,20 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"target": "es2021",
"keepClassNames": true,
"loose": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"module": {
"type": "commonjs"
},
"sourceMaps": true
}

35
api/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
FROM node:18-alpine AS installer
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/merge-extensions-deps.js ./
COPY --from=builder /app/src/extensions ./src/extensions
COPY --from=builder /app/patches ./patches
RUN npm update -g npm
RUN npm config set registry https://registry.npmjs.com/
RUN npm i --verbose --maxsockets 6
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=installer /app/ .
COPY . .
EXPOSE 3000
CMD [ "npm", "run" , "start:dev" ]

145
api/README.md Normal file
View File

@@ -0,0 +1,145 @@
# Hexabot API
[Hexabot](https://hexabot.ai/)'s API is a RESTful API built with NestJS, designed to handle requests from both the UI admin panel and various communication channels. The API powers core functionalities such as chatbot management, message flow, NLP (Natural Language Processing), and plugin integrations.
## Key Features
- **RESTful Architecture:** Simple, standardized API architecture following REST principles.
- **Multi-Channel Support:** Handles requests from different communication channels (e.g., web, mobile).
- **Modular Design:** Organized into multiple modules for better scalability and maintainability.
- **Real-Time Communication:** Integrates WebSocket support for real-time features.
## API Modules
The API is divided into several key modules, each responsible for specific functionalities:
### Core Modules
- **Analytics:** Tracks and serves analytics data such as the number of messages exchanged and end-user retention statistics.
- **Attachment:** Manages file uploads and downloads, enabling attachment handling across the chatbot.
- **Channel:** Manages different communication channels through which the chatbot operates (e.g., web, mobile apps, etc.).
- **Chat:** The core module for handling incoming channel requests and managing the chat flow as defined by the visual editor in the UI.
- **CMS:** Content management module for defining content types, managing content, and configuring menus for chatbot interactions.
- **NLP:** Manages NLP entities such as intents, languages, and values used to detect and process user inputs intelligently.
- **Plugins:** Manages extensions and plugins that integrate additional features and functionalities into the chatbot.
- **User:** Manages user authentication, roles, and permissions, ensuring secure access to different parts of the system.
- **Extensions:** A container for all types of extensions (channels, plugins, helpers) that can be added to expand the chatbot's functionality.
- **Settings:** A module for management all types of settings that can be adjusted to customize the chatbot.
### Utility Modules
- **WebSocket:** Adds support for Websicket with Socket.IO, enabling real-time communication for events like live chat and user interactions.
- **Logger:** Provides logging functionality to track and debug API requests and events.
## Installation
```bash
$ npm install
```
## Running the app in standalone
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Migrations
The API includes a migrations feature to help manage database schema and data changes over time. Migrations allow you to apply or revert changes to the database in a consistent and controlled manner.
### Creating a Migration
You need to navigate to the `api` folder to run the following commands.
To create a new migration, use the following command:
```bash
$ npm run create-migration <migration-name>
```
Example:
```bash
$ npm run create-migration all-users-language-fr
```
This command generates a new migration file in the `./migrations` folder. The file will look like this:
```typescript
import getModels from '@/models/index';
export async function up(): Promise<void> {
// Write migration here
}
export async function down(): Promise<void> {
// Write migration here
}
```
Within the migration file, you can define the changes to be made in the up() function. For example, if you want to update the language field of all users to 'fr', your migration might look like this:
```typescript
import getModels from '@/models/index';
export async function up(): Promise<void> {
const { UserModel } = await getModels();
await UserModel.updateMany({}, { language: 'fr' });
}
export async function down(): Promise<void> {}
```
### Running Migrations Up
All migrations are run automatically when the app starts.
Alternatively, you can run the following command in the `root` directory to run all pending migrations:
```bash
$ make migrate-up
```
### Running Migrations Manually
If you want to run specific actions manually, you need to gain access to the `api` container and use the following command to run what you specifically want:
```bash
$ npm run migrate -h
```
## Documentation
Access the Swagger API documentation by visiting the API url `/docs` once run it in development mode.
It's also possible to access the API reference documentation by running `npm run doc`.
For detailed information about the API routes and usage, refer to the API documentation or visit [https://docs.hexabot.ai](https://docs.hexabot.ai).
## Contributing
We welcome contributions from the community! Whether you want to report a bug, suggest new features, or submit a pull request, your input is valuable to us.
Feel free to join us on [Discord](https://discord.gg/xnpWDYQMAq)
## License
This software is licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.

Binary file not shown.

View File

@@ -0,0 +1,80 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
// Define the paths
const rootPackageJsonPath = path.join(__dirname, 'package.json');
const pluginsDir = path.join(__dirname, 'src', 'extensions', 'plugins');
const channelsDir = path.join(__dirname, 'src', 'extensions', 'channels');
const helpersDir = path.join(__dirname, 'src', 'extensions', 'helpers');
// Helper function to merge dependencies
function mergeDependencies(rootDeps, pluginDeps) {
return {
...rootDeps,
...Object.entries(pluginDeps).reduce((acc, [key, version]) => {
if (!rootDeps[key]) {
acc[key] = version;
}
return acc;
}, {}),
};
}
// Read the root package.json
const rootPackageJson = JSON.parse(
fs.readFileSync(rootPackageJsonPath, 'utf-8'),
);
// Initialize dependencies if not already present
if (!rootPackageJson.dependencies) {
rootPackageJson.dependencies = {};
}
// Iterate over extension directories
[
...fs.readdirSync(pluginsDir),
...fs.readdirSync(helpersDir),
...fs.readdirSync(channelsDir),
].forEach((pluginFolder) => {
const pluginPackageJsonPath = path.join(
pluginsDir,
pluginFolder,
'package.json',
);
if (fs.existsSync(pluginPackageJsonPath)) {
const pluginPackageJson = JSON.parse(
fs.readFileSync(pluginPackageJsonPath, 'utf-8'),
);
// Merge extension dependencies into root dependencies
if (pluginPackageJson.dependencies) {
rootPackageJson.dependencies = mergeDependencies(
rootPackageJson.dependencies,
pluginPackageJson.dependencies,
);
}
// Merge extension devDependencies into root devDependencies
if (pluginPackageJson.devDependencies) {
rootPackageJson.devDependencies = mergeDependencies(
rootPackageJson.devDependencies,
pluginPackageJson.devDependencies,
);
}
}
});
// Write the updated root package.json
fs.writeFileSync(
rootPackageJsonPath,
JSON.stringify(rootPackageJson, null, 2),
'utf-8',
);
// eslint-disable-next-line no-console
console.log(
'Dependencies from extensions have been merged into the root package.json',
);

View File

@@ -0,0 +1,62 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
/* eslint-disable no-console */
import fs from 'fs';
import path from 'path';
// Get the argument passed (e.g., "all-users-fr")
const arg: string | undefined = process.argv[2];
// Check if the argument exists
if (!arg) {
console.error('Please provide a name for a new migration.');
process.exit(1);
}
// Define the path to the migrations directory and the template file
const migrationsDir: string = path.join(__dirname, '../');
const templatePath: string = path.join(__dirname, '../config/template.ts');
// Check if a migration with the same name (excluding timestamp) already exists
const migrationExists: boolean = fs.readdirSync(migrationsDir).some((file) => {
const regex = new RegExp(`^[0-9]+-${arg}\.ts$`);
return regex.test(file);
});
if (migrationExists) {
console.error(`A migration with the name "${arg}" already exists.`);
process.exit(1);
}
// Generate a timestamp
const timestamp: string = Date.now().toString();
// Create the filename using the timestamp and argument
const filename: string = `${timestamp}-${arg}.ts`;
// Read the template content from the file
let template: string;
try {
template = fs.readFileSync(templatePath, 'utf8');
} catch (err) {
console.error('Error reading template file:', err);
process.exit(1);
}
// Define the full path to save the file
const filePath: string = path.join(migrationsDir, filename);
// Write the template to the file
fs.writeFile(filePath, template, (err) => {
if (err) {
console.error('Error writing file:', err);
} else {
console.log(`File created: ${filename}`);
}
});

View File

@@ -0,0 +1,18 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { config } from '@/config';
export default {
uri: `${config.mongo.uri}${config.mongo.dbName}?authSource=admin`,
collection: 'migrations',
migrationsPath: './migrations',
templatePath: './migrations/config/template.ts',
autosync: false,
};

View File

@@ -0,0 +1,18 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import getModels from 'migrations/models/index';
export async function up(): Promise<void> {
// Write migration here
}
export async function down(): Promise<void> {
// Write migration here
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import mongoose from 'mongoose';
import leanDefaults from 'mongoose-lean-defaults';
import leanGetters from 'mongoose-lean-getters';
import leanVirtuals from 'mongoose-lean-virtuals';
import botStatsSchema, { BotStats } from '@/analytics/schemas/bot-stats.schema';
import attachmentSchema, {
Attachment,
} from '@/attachment/schemas/attachment.schema';
import blockSchema, { Block } from '@/chat/schemas/block.schema';
import contextVarSchema, {
ContextVar,
} from '@/chat/schemas/context-var.schema';
import conversationSchema, {
Conversation,
} from '@/chat/schemas/conversation.schema';
import labelSchema, { Label } from '@/chat/schemas/label.schema';
import messageSchema, { Message } from '@/chat/schemas/message.schema';
import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema';
import translationSchema, {
Translation,
} from '@/chat/schemas/translation.schema';
import { ContentType } from '@/cms/schemas/content-type.schema';
import contentSchema, { Content } from '@/cms/schemas/content.schema';
import menuSchema, { Menu } from '@/cms/schemas/menu.schema';
import { config } from '@/config';
import nlpEntitySchema, { NlpEntity } from '@/nlp/schemas/nlp-entity.schema';
import nlpSampleEntitySchema, {
NlpSampleEntity,
} from '@/nlp/schemas/nlp-sample-entity.schema';
import nlpSampleSchema, { NlpSample } from '@/nlp/schemas/nlp-sample.schema';
import nlpValueSchema, { NlpValue } from '@/nlp/schemas/nlp-value.schema';
import settingSchema, { Setting } from '@/setting/schemas/setting.schema';
import invitationSchema, { Invitation } from '@/user/schemas/invitation.schema';
import modelSchema, { Model } from '@/user/schemas/model.schema';
import permissionSchema, { Permission } from '@/user/schemas/permission.schema';
import roleSchema, { Role } from '@/user/schemas/role.schema';
import userSchema, { User } from '@/user/schemas/user.schema';
import idPlugin from '@/utils/schema-plugin/id.plugin';
async function mongoMigrationConnection() {
try {
const connection = await mongoose.connect(config.mongo.uri, {
dbName: config.mongo.dbName,
});
connection.plugin(idPlugin);
connection.plugin(leanVirtuals);
connection.plugin(leanGetters);
connection.plugin(leanDefaults);
} catch (err) {
throw err;
}
}
async function getModels() {
await mongoMigrationConnection();
const AttachementModel = mongoose.model<Attachment>(
Attachment.name,
attachmentSchema,
);
const BlockModel = mongoose.model<Block>(Block.name, blockSchema);
const BotstatsModel = mongoose.model<BotStats>(BotStats.name, botStatsSchema);
const ContentModel = mongoose.model<Content>(Content.name, contentSchema);
const ContenttypeModel = mongoose.model<ContentType>(
ContentType.name,
contentSchema,
);
const ContextVarModel = mongoose.model<ContextVar>(
ContextVar.name,
contextVarSchema,
);
const ConversationModel = mongoose.model<Conversation>(
Conversation.name,
conversationSchema,
);
const InvitationModel = mongoose.model<Invitation>(
Invitation.name,
invitationSchema,
);
const LabelModel = mongoose.model<Label>(Label.name, labelSchema);
const MenuModel = mongoose.model<Menu>(Menu.name, menuSchema);
const MessageModel = mongoose.model<Message>(Message.name, messageSchema);
const ModelModel = mongoose.model<Model>(Model.name, modelSchema);
const NlpEntityModel = mongoose.model<NlpEntity>(
NlpEntity.name,
nlpEntitySchema,
);
const NlpSampleEntityModel = mongoose.model<NlpSampleEntity>(
NlpSampleEntity.name,
nlpSampleEntitySchema,
);
const NlpSampleModel = mongoose.model<NlpSample>(
NlpSample.name,
nlpSampleSchema,
);
const NlpValueModel = mongoose.model<NlpValue>(NlpValue.name, nlpValueSchema);
const PermissionModel = mongoose.model<Permission>(
Permission.name,
permissionSchema,
);
const RoleModel = mongoose.model<Role>(Role.name, roleSchema);
const SettingModel = mongoose.model<Setting>(Setting.name, settingSchema);
const SubscriberModel = mongoose.model(Subscriber.name, subscriberSchema);
const TranslationModel = mongoose.model<Translation>(
Translation.name,
translationSchema,
);
const UserModel = mongoose.model<User>(User.name, userSchema);
return {
AttachementModel,
BlockModel,
BotstatsModel,
ContentModel,
ContenttypeModel,
ContextVarModel,
ConversationModel,
InvitationModel,
LabelModel,
MenuModel,
MessageModel,
ModelModel,
NlpEntityModel,
NlpSampleEntityModel,
NlpSampleModel,
NlpValueModel,
PermissionModel,
RoleModel,
SettingModel,
SubscriberModel,
TranslationModel,
UserModel,
};
}
export default getModels;

9
api/nest-cli.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": [{ "include": "config/i18n/**/*", "watchAssets": true }]
}
}

18738
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

157
api/package.json Normal file
View File

@@ -0,0 +1,157 @@
{
"name": "hexabot",
"private": true,
"version": "2.0.0",
"description": "Hexabot is a solution for creating and managing chatbots across multiple channels, leveraging AI for advanced conversational capabilities. It provides a user-friendly interface for building, training, and deploying chatbots with integrated support for various messaging platforms.",
"author": "Hexastack",
"license": "AGPL-3.0-only",
"scripts": {
"preinstall": "node merge-extensions-deps.js",
"postinstall": "patch-package",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"doc": "npx @compodoc/compodoc --hideGenerator -p tsconfig.doc.json -s -r 9003 -w",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug 0.0.0.0:9229 --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --runInBand --logHeapUsage --detectOpenHandles --forceExit",
"test:half": "jest --logHeapUsage --maxWorkers=50% --testTimeout=10000",
"test:full": "jest --logHeapUsage --maxWorkers=100% --testTimeout=10000",
"test:watch": "jest --watch --detectOpenHandles",
"test:cov": "jest --coverage --runInBand --detectOpenHandles --forceExit",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"cache:init": "ts-node -T ./src/database-init.ts",
"typecheck": "tsc --noEmit",
"containers:restart": "cd .. && make init && make stop && make start",
"containers:rebuild": "cd .. && make init && make destroy && make start",
"reset": "npm install && npm run containers:restart",
"reset:hard": "npm clean-install && npm run containers:rebuild",
"migrate": "./node_modules/ts-migrate-mongoose/dist/cjs/bin.js --config-path ./migrations/config/migrate.ts",
"create-migration": "ts-node ./migrations/config/create.ts"
},
"dependencies": {
"@nestjs-modules/mailer": "^1.11.2",
"@nestjs/axios": "^3.0.3",
"@nestjs/cache-manager": "^2.2.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.4",
"@nestjs/mongoose": "^10.0.2",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.3.7",
"@nestjs/websockets": "^10.3.7",
"@resvg/resvg-js": "^2.6.2",
"@tekuconcept/nestjs-csrf": "^1.1.0",
"bcryptjs": "^2.4.3",
"cache-manager": "^5.3.2",
"connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"express-session": "^1.17.3",
"joi": "^17.11.0",
"module-alias": "^2.2.3",
"mongoose": "^8.0.0",
"mongoose-lean-defaults": "^2.2.1",
"mongoose-lean-getters": "^1.1.0",
"mongoose-lean-virtuals": "^0.9.1",
"multer": "^1.4.5-lts.1",
"nestjs-dynamic-providers": "^0.3.4",
"nestjs-i18n": "^10.4.0",
"nodemailer": "^6.9.13",
"openai": "^4.54.0",
"papaparse": "^5.4.1",
"passport": "^0.6.0",
"passport-anonymous": "^1.0.1",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"patch-package": "^8.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"slug": "^8.2.2",
"ts-migrate-mongoose": "^3.8.3",
"uuid": "^9.0.1"
},
"devDependencies": {
"@compodoc/compodoc": "^1.1.24",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/swagger": "^7.2.0",
"@nestjs/testing": "^10.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.6",
"@types/cookie-signature": "^1.1.2",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.10",
"@types/jest": "^29.5.2",
"@types/module-alias": "^2.0.4",
"@types/multer": "^1.4.11",
"@types/node": "^20.3.1",
"@types/node-fetch": "^2.6.11",
"@types/nodemailer": "^6.4.14",
"@types/papaparse": "^5.3.14",
"@types/passport-anonymous": "^1.0.5",
"@types/passport-jwt": "^3.0.13",
"@types/passport-local": "^1.0.38",
"@types/slug": "^5.0.3",
"@types/supertest": "^2.0.12",
"@types/uid-safe": "^2.1.5",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "~3.6.1",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^8.0.0",
"jest": "^29.5.0",
"mongodb-memory-server": "^9.1.6",
"nock": "^13.5.1",
"prettier": "^3.0.0",
"socket.io-client": "^4.7.5",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"tsconfig-paths-jest": "^0.0.1",
"typescript": "^5.1.3",
"@types/minio": "^7.1.1"
},
"jest": {
"globalSetup": "<rootDir>/../test/global-setup.ts",
"globalTeardown": "<rootDir>/../test/global-teardown.ts",
"setupFiles": [
"<rootDir>/../test/setup-tests.ts"
],
"setupFilesAfterEnv": [
"<rootDir>/../test/jest.setup.ts"
],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/$1"
}
}
}

View File

@@ -0,0 +1,17 @@
diff --git a/node_modules/@nestjs-modules/mailer/dist/mailer.service.js b/node_modules/@nestjs-modules/mailer/dist/mailer.service.js
index 016055b..d534240 100644
--- a/node_modules/@nestjs-modules/mailer/dist/mailer.service.js
+++ b/node_modules/@nestjs-modules/mailer/dist/mailer.service.js
@@ -63,9 +63,9 @@ let MailerService = MailerService_1 = class MailerService {
}
verifyTransporter(transporter, name) {
const transporterName = name ? ` '${name}'` : '';
- transporter.verify()
- .then(() => this.mailerLogger.error(`Transporter${transporterName} is ready`))
- .catch((error) => this.mailerLogger.error(`Error occurred while verifying the transporter${transporterName}}: ${error.message}`));
+ new Promise(()=>transporter?.verify())
+ .then(() => this.mailerLogger.log(`Transporter${transporterName} is ready`))
+ .catch((error) => this.mailerLogger.log(`Error occurred while verifying the transporter${transporterName}}: ${error.message}`));
}
sendMail(sendMailOptions) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {

View File

@@ -0,0 +1,72 @@
diff --git a/node_modules/ts-migrate-mongoose/dist/cjs/migrator.js b/node_modules/ts-migrate-mongoose/dist/cjs/migrator.js
index e319b94..342aa69 100644
--- a/node_modules/ts-migrate-mongoose/dist/cjs/migrator.js
+++ b/node_modules/ts-migrate-mongoose/dist/cjs/migrator.js
@@ -115,7 +115,8 @@ class Migrator {
.filter((file) => !file.existsInDatabase)
.map((file) => file.filename);
this.log('Synchronizing database with file system migrations...');
- migrationsToImport = await this.choseMigrations(migrationsToImport, 'The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database');
+ // This line is commented out because it is not necessary to ask the user to import the migrations into the database
+ // migrationsToImport = await this.choseMigrations(migrationsToImport, 'The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database');
return this.syncMigrations(migrationsToImport);
}
catch (error) {
@@ -133,7 +134,8 @@ class Migrator {
let migrationsToDelete = migrationsInDb
.filter((migration) => !migrationsInFs.find((file) => file.filename === migration.filename))
.map((migration) => migration.name);
- migrationsToDelete = await this.choseMigrations(migrationsToDelete, 'The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database');
+ // This line is commented out because it is not necessary to ask the user to delete the migrations from the database
+ // migrationsToDelete = await this.choseMigrations(migrationsToDelete, 'The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database');
if (migrationsToDelete.length) {
migrationsDeleted = await this.migrationModel.find({ name: { $in: migrationsToDelete } }).exec();
this.log(`Removing migration(s) from database: \n${chalk_1.default.cyan(migrationsToDelete.join('\n'))} `);
diff --git a/node_modules/ts-migrate-mongoose/dist/esm/migrator.js b/node_modules/ts-migrate-mongoose/dist/esm/migrator.js
index 7ad7150..11e951c 100644
--- a/node_modules/ts-migrate-mongoose/dist/esm/migrator.js
+++ b/node_modules/ts-migrate-mongoose/dist/esm/migrator.js
@@ -111,7 +111,8 @@ class Migrator {
.filter((file) => !file.existsInDatabase)
.map((file) => file.filename);
this.log('Synchronizing database with file system migrations...');
- migrationsToImport = await this.choseMigrations(migrationsToImport, 'The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database');
+ // This line is commented out because it is not necessary to ask the user to import the migrations into the database
+ // migrationsToImport = await this.choseMigrations(migrationsToImport, 'The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database');
return this.syncMigrations(migrationsToImport);
}
catch (error) {
@@ -129,7 +130,8 @@ class Migrator {
let migrationsToDelete = migrationsInDb
.filter((migration) => !migrationsInFs.find((file) => file.filename === migration.filename))
.map((migration) => migration.name);
- migrationsToDelete = await this.choseMigrations(migrationsToDelete, 'The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database');
+ // This line is commented out because it is not necessary to ask the user to delete the migrations from the database
+ // migrationsToDelete = await this.choseMigrations(migrationsToDelete, 'The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database');
if (migrationsToDelete.length) {
migrationsDeleted = await this.migrationModel.find({ name: { $in: migrationsToDelete } }).exec();
this.log(`Removing migration(s) from database: \n${chalk.cyan(migrationsToDelete.join('\n'))} `);
diff --git a/node_modules/ts-migrate-mongoose/src/migrator.ts b/node_modules/ts-migrate-mongoose/src/migrator.ts
index db54f87..7256796 100644
--- a/node_modules/ts-migrate-mongoose/src/migrator.ts
+++ b/node_modules/ts-migrate-mongoose/src/migrator.ts
@@ -202,7 +202,8 @@ class Migrator {
.map((file) => file.filename)
this.log('Synchronizing database with file system migrations...')
- migrationsToImport = await this.choseMigrations(migrationsToImport, 'The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database')
+ // This line is commented out because it is not necessary to ask the user to import the migrations into the database
+ // migrationsToImport = await this.choseMigrations(migrationsToImport, 'The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database')
return this.syncMigrations(migrationsToImport)
} catch (error) {
@@ -232,7 +233,8 @@ class Migrator {
.filter((migration) => !migrationsInFs.find((file) => file.filename === migration.filename))
.map((migration) => migration.name)
- migrationsToDelete = await this.choseMigrations(migrationsToDelete, 'The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database')
+ // This line is commented out because it is not necessary to ask the user to delete the migrations from the database
+ // migrationsToDelete = await this.choseMigrations(migrationsToDelete, 'The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database')
if (migrationsToDelete.length) {
migrationsDeleted = await this.migrationModel.find({ name: { $in: migrationsToDelete } }).exec()

View File

@@ -0,0 +1,24 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Module } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { BotStatsController } from './controllers/bot-stats.controller';
import { BotStatsRepository } from './repositories/bot-stats.repository';
import { BotStatsModel } from './schemas/bot-stats.schema';
import { BotStatsService } from './services/bot-stats.service';
@Module({
imports: [MongooseModule.forFeature([BotStatsModel]), EventEmitter2],
controllers: [BotStatsController],
providers: [BotStatsService, BotStatsRepository],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,216 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import {
botstatsFixtures,
installBotStatsFixtures,
} from '@/utils/test/fixtures/botstats';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { BotStatsController } from './bot-stats.controller';
import { BotStatsRepository } from '../repositories/bot-stats.repository';
import { BotStatsModel, BotStatsType } from '../schemas/bot-stats.schema';
import { BotStatsService } from '../services/bot-stats.service';
describe('BotStatsController', () => {
let botStatsController: BotStatsController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [BotStatsController],
imports: [
rootMongooseTestModule(installBotStatsFixtures),
MongooseModule.forFeature([BotStatsModel]),
],
providers: [
LoggerService,
BotStatsService,
BotStatsRepository,
EventEmitter2,
],
}).compile();
botStatsController = module.get<BotStatsController>(BotStatsController);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findMessages', () => {
it('should return no messages in the given date range', async () => {
const result = await botStatsController.findMessages({
from: new Date('2024-11-01T23:00:00.000Z'),
to: new Date('2024-11-05T23:00:00.000Z'),
});
expect(result).toHaveLength(3);
expect(result).toEqualPayload([
{
id: 1,
name: BotStatsType.all_messages,
values: [],
},
{
id: 2,
name: BotStatsType.incoming,
values: [],
},
{
id: 3,
name: BotStatsType.outgoing,
values: [],
},
]);
});
it('should return messages in the given date range', async () => {
const from = new Date('2023-11-01T23:00:00.000Z');
const to = new Date('2023-11-05T23:00:00.000Z');
const result = await botStatsController.findMessages({
from,
to,
});
expect(result).toEqualPayload([
{
id: 1,
name: BotStatsType.all_messages,
values: [
{
...botstatsFixtures[0],
date: botstatsFixtures[0].day,
},
],
},
{
id: 2,
name: BotStatsType.incoming,
values: [
{
...botstatsFixtures[4],
date: botstatsFixtures[4].day,
},
],
},
{
id: 3,
name: BotStatsType.outgoing,
values: [],
},
]);
});
});
describe('datum', () => {
it('should return messages of a given type', async () => {
const result = await botStatsController.datum({
from: new Date('2023-11-06T23:00:00.000Z'),
to: new Date('2023-11-08T23:00:00.000Z'),
type: BotStatsType.outgoing,
});
expect(result).toEqualPayload([
{
id: 1,
name: BotStatsType.outgoing,
values: [
{
...botstatsFixtures[5],
date: botstatsFixtures[5].day,
},
],
},
]);
});
});
describe('conversation', () => {
it('should return conversation messages', async () => {
const result = await botStatsController.conversation({
from: new Date('2023-11-04T23:00:00.000Z'),
to: new Date('2023-11-06T23:00:00.000Z'),
});
expect(result).toEqualPayload([
{
id: 1,
name: BotStatsType.new_conversations,
values: [
{
...botstatsFixtures[3],
date: botstatsFixtures[3].day,
},
],
},
{
id: 2,
name: BotStatsType.existing_conversations,
values: [],
},
]);
});
});
describe('audiance', () => {
it('should return audiance messages', async () => {
const result = await botStatsController.audiance({
from: new Date('2023-11-01T23:00:00.000Z'),
to: new Date('2023-11-08T23:00:00.000Z'),
});
expect(result).toEqualPayload([
{
id: 1,
name: BotStatsType.new_users,
values: [
{
...botstatsFixtures[1],
date: botstatsFixtures[1].day,
},
],
},
{
id: 2,
name: BotStatsType.returning_users,
values: [],
},
{
id: 3,
name: BotStatsType.retention,
values: [],
},
]);
});
});
describe('popularBlocks', () => {
it('should return popular blocks', async () => {
const result = await botStatsController.popularBlocks({
from: new Date('2023-11-01T23:00:00.000Z'),
to: new Date('2023-11-08T23:00:00.000Z'),
});
expect(result).toEqual([
{
name: 'Global Fallback',
id: 'Global Fallback',
value: 68,
},
]);
});
});
});

View File

@@ -0,0 +1,119 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Controller, Get, Query } from '@nestjs/common';
import { ToLinesType } from './../schemas/bot-stats.schema';
import { BotStatsFindDatumDto, BotStatsFindDto } from '../dto/bot-stats.dto';
import { BotStats, BotStatsType } from '../schemas/bot-stats.schema';
import { BotStatsService } from '../services/bot-stats.service';
import { aMonthAgo } from '../utilities';
@Controller('botstats')
export class BotStatsController {
constructor(private readonly botStatsService: BotStatsService) {}
/**
* Retrieves message stats within a specified time range.
*
* @param dto - Parameters for filtering messages (Start & End dates).
* @returns A promise that resolves to an array of messages formatted for the line chart.
*/
@Get('messages')
async findMessages(
@Query()
dto: BotStatsFindDto,
): Promise<ToLinesType[]> {
const { from = aMonthAgo(), to = new Date() } = dto;
const types: BotStatsType[] = [
BotStatsType.all_messages,
BotStatsType.incoming,
BotStatsType.outgoing,
];
const result = await this.botStatsService.findMessages(from, to, types);
return BotStats.toLines(result, types);
}
/**
* Retrieves message stats within a specified time range for a given message type
*
* @param dto - Parameters for filtering data (Start & End dates, Type).
* @returns A promise that resolves to an array of data formatted as lines.
*/
@Get('datum')
async datum(
@Query()
dto: BotStatsFindDatumDto,
): Promise<ToLinesType[]> {
const { from = aMonthAgo(), to = new Date(), type } = dto;
const result = await this.botStatsService.findMessages(from, to, [type]);
return BotStats.toLines(result, [type]);
}
/**
* Retrieves conversation message stats within a specified time range
*
* @param dto - Parameters for filtering data (Start & End dates, Type).
* @returns A promise that resolves to an array of data formatted for the line chart.
*/
@Get('conversation')
async conversation(
@Query()
dto: BotStatsFindDto,
): Promise<ToLinesType[]> {
const { from = aMonthAgo(), to = new Date() } = dto;
const types: BotStatsType[] = [
BotStatsType.new_conversations,
BotStatsType.existing_conversations,
];
const result = await this.botStatsService.findMessages(from, to, types);
return BotStats.toLines(result, types);
}
/**
* Retrieves audience message stats within a specified time range.
*
* @param dto - Parameters for filtering messages (Start & End dates).
* @returns A promise that resolves to an array of data formatted for the line chart.
*/
@Get('audiance')
async audiance(
@Query()
dto: BotStatsFindDto,
): Promise<ToLinesType[]> {
const { from = aMonthAgo(), to = new Date() } = dto;
const types: BotStatsType[] = [
BotStatsType.new_users,
BotStatsType.returning_users,
BotStatsType.retention,
];
const result = await this.botStatsService.findMessages(from, to, types);
return BotStats.toLines(result, types);
}
/**
* Retrieves popular blocks stats within a specified time range.
*
* @param dto - Parameters for filtering messages (Start & End dates).
* @returns A promise that resolves to an array of data formatted for the bar chart.
*/
@Get('popularBlocks')
async popularBlocks(
@Query()
dto: BotStatsFindDto,
): Promise<{ id: string; name: string; value: number }[]> {
const { from = aMonthAgo(), to = new Date() } = dto;
const results = await this.botStatsService.findPopularBlocks(from, to);
return BotStats.toBars(results);
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Type } from 'class-transformer';
import {
IsDate,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { BotStatsType } from '../schemas/bot-stats.schema';
import { IsLessThanDate } from '../validation-rules/is-less-than-date';
export class BotStatsCreateDto {
@IsNotEmpty()
@IsString()
type: BotStatsType;
@IsString()
@IsNotEmpty()
day: Date;
@IsNotEmpty()
@IsNumber()
value: number;
@IsString()
@IsNotEmpty()
name: string;
}
export class BotStatsFindDto {
/**
* Start date for message retrieval.
*/
@IsDate()
@Type(() => Date)
@IsOptional()
@IsLessThanDate('to', {
message: 'From date must be less than or equal to To date',
})
from?: Date;
/**
* End date for message retrieval.
*/
@IsDate()
@Type(() => Date)
@IsOptional()
to?: Date;
}
export class BotStatsFindDatumDto extends BotStatsFindDto {
/**
* Type for message to retrieve.
*/
@IsEnum(BotStatsType)
@IsNotEmpty()
type: BotStatsType;
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { Model } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import {
botstatsFixtures,
installBotStatsFixtures,
} from '@/utils/test/fixtures/botstats';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { BotStatsRepository } from './bot-stats.repository';
import {
BotStats,
BotStatsModel,
BotStatsType,
} from '../schemas/bot-stats.schema';
describe('BotStatsRepository', () => {
let botStatsRepository: BotStatsRepository;
let botStatsModel: Model<BotStats>;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installBotStatsFixtures),
MongooseModule.forFeature([BotStatsModel]),
],
providers: [LoggerService, BotStatsRepository],
}).compile();
botStatsRepository = module.get<BotStatsRepository>(BotStatsRepository);
botStatsModel = module.get<Model<BotStats>>(getModelToken('BotStats'));
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findMessages', () => {
it('should return messages', async () => {
jest.spyOn(botStatsModel, 'find');
const from = new Date('2023-11-01T23:00:00.000Z');
const to = new Date('2023-11-07T23:00:00.000Z');
const types = [
BotStatsType.all_messages,
BotStatsType.incoming,
BotStatsType.outgoing,
];
const result = await botStatsRepository.findMessages(from, to, types);
expect(botStatsModel.find).toHaveBeenCalledWith({
type: {
$in: [
BotStatsType.all_messages,
BotStatsType.incoming,
BotStatsType.outgoing,
],
},
day: { $gte: from, $lte: to },
});
expect(result).toEqualPayload(
botstatsFixtures.filter(({ type }) => types.includes(type)),
);
});
it('should return messages of a specific period', async () => {
jest.spyOn(botStatsModel, 'find');
const from = new Date('2023-11-01T23:00:00.000Z');
const to = new Date('2023-11-03T23:00:00.000Z');
const types = [
BotStatsType.all_messages,
BotStatsType.incoming,
BotStatsType.outgoing,
];
const result = await botStatsRepository.findMessages(from, to, types);
expect(botStatsModel.find).toHaveBeenCalledWith({
type: {
$in: [
BotStatsType.all_messages,
BotStatsType.incoming,
BotStatsType.outgoing,
],
},
day: { $gte: from, $lte: to },
});
expect(result).toEqualPayload([botstatsFixtures[0]]);
});
it('should return conversation statistics', async () => {
jest.spyOn(botStatsModel, 'find');
const from = new Date('2023-11-01T23:00:00.000Z');
const to = new Date('2023-11-07T23:00:00.000Z');
const result = await botStatsRepository.findMessages(from, to, [
BotStatsType.new_conversations,
BotStatsType.existing_conversations,
]);
expect(botStatsModel.find).toHaveBeenCalledWith({
type: {
$in: [
BotStatsType.new_conversations,
BotStatsType.existing_conversations,
],
},
day: { $gte: from, $lte: to },
});
expect(result).toEqualPayload([botstatsFixtures[3]]);
});
it('should return audiance statistics', async () => {
jest.spyOn(botStatsModel, 'find');
const from = new Date('2023-11-01T23:00:00.000Z');
const to = new Date('2023-11-07T23:00:00.000Z');
const result = await botStatsRepository.findMessages(from, to, [
BotStatsType.new_users,
BotStatsType.returning_users,
BotStatsType.retention,
]);
expect(botStatsModel.find).toHaveBeenCalledWith({
type: {
$in: [
BotStatsType.new_users,
BotStatsType.returning_users,
BotStatsType.retention,
],
},
day: { $gte: from, $lte: to },
});
expect(result).toEqualPayload([botstatsFixtures[1]]);
});
it('should return statistics of a given type', async () => {
jest.spyOn(botStatsModel, 'find');
const from = new Date('2023-11-01T23:00:00.000Z');
const to = new Date('2023-11-07T23:00:00.000Z');
const result = await botStatsRepository.findMessages(from, to, [
BotStatsType.incoming,
]);
expect(botStatsModel.find).toHaveBeenCalledWith({
type: {
$in: [BotStatsType.incoming],
},
day: { $gte: from, $lte: to },
});
expect(result).toEqualPayload([botstatsFixtures[4]]);
});
});
describe('findPopularBlocks', () => {
it('should return popular blocks', async () => {
jest.spyOn(botStatsModel, 'aggregate');
const from = new Date('2023-11-01T22:00:00.000Z');
const to = new Date('2023-11-07T23:00:00.000Z');
const result = await botStatsRepository.findPopularBlocks(from, to);
expect(botStatsModel.aggregate).toHaveBeenCalled();
expect(result).toEqual([
{
id: 'Global Fallback',
value: 68,
},
]);
});
});
});

View File

@@ -0,0 +1,87 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { BotStats, BotStatsType } from '../schemas/bot-stats.schema';
@Injectable()
export class BotStatsRepository extends BaseRepository<BotStats, never> {
constructor(@InjectModel(BotStats.name) readonly model: Model<BotStats>) {
super(model, BotStats);
}
/**
* Retrieves message statistics based on the provided types and time range.
*
* @param from - Start date for filtering messages.
* @param to - End date for filtering messages.
* @param types - An array of message types to filter.
* @returns A promise that resolves to an array of message statistics.
*/
async findMessages(
from: Date,
to: Date,
types: BotStatsType[],
): Promise<BotStats[]> {
const query = this.model.find({
type: { $in: types },
day: { $gte: from, $lte: to },
});
return await this.execute(query, BotStats);
}
/**
* Retrieves the aggregated sum of values for popular blocks within a specified time range.
*
* @param from Start date for the time range
* @param to End date for the time range
* @param limit Optional maximum number of results to return (defaults to 5)
* @returns A promise that resolves to an array of objects containing the block ID and the aggregated value
*/
async findPopularBlocks(
from: Date,
to: Date,
limit: number = 5,
): Promise<{ id: string; value: number }[]> {
return await this.model.aggregate([
{
$match: {
day: { $gte: from, $lte: to },
type: BotStatsType.popular,
},
},
{
$group: {
_id: '$name',
id: { $sum: 1 },
value: { $sum: '$value' },
},
},
{
$sort: {
value: -1,
},
},
{
$limit: limit,
},
{
$addFields: { id: '$_id' },
},
{
$project: { _id: 0 },
},
]);
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { botstatsFixtures } from '@/utils/test/fixtures/botstats';
import { BotStats, BotStatsType } from './bot-stats.schema';
describe('toLines', () => {
it('should transform the data based on the given types', () => {
const result = BotStats.toLines(
[
{
...botstatsFixtures[4],
id: '1',
createdAt: new Date(),
updatedAt: new Date(),
},
{
...botstatsFixtures[5],
id: '2',
createdAt: new Date(),
updatedAt: new Date(),
},
],
[BotStatsType.incoming, BotStatsType.outgoing],
);
expect(result).toEqualPayload([
{
id: 1,
name: BotStatsType.incoming,
values: [
{
...botstatsFixtures[4],
date: botstatsFixtures[4].day,
},
],
},
{
id: 2,
name: BotStatsType.outgoing,
values: [
{
...botstatsFixtures[5],
date: botstatsFixtures[5].day,
},
],
},
]);
});
});

View File

@@ -0,0 +1,124 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
export enum BotStatsType {
outgoing = 'outgoing',
new_users = 'new_users',
all_messages = 'all_messages',
incoming = 'incoming',
existing_conversations = 'existing_conversations',
popular = 'popular',
new_conversations = 'new_conversations',
returning_users = 'returning_users',
retention = 'retention',
}
export type ToLinesType = {
id: number;
name: BotStatsType;
values: any[];
};
@Schema({ timestamps: true })
export class BotStats extends BaseSchema {
/**
* Type of the captured insight.
*/
@Prop({
type: String,
required: true,
})
type: BotStatsType;
/**
* Day based granularity for the captured insights.
*/
@Prop({
type: Date,
required: true,
})
day: Date;
/**
* Total value of the insight for the whole chosen granularity.
*/
@Prop({ type: Number, default: 0 })
value?: number;
/**
* name of the insight (e.g: incoming messages).
*/
@Prop({ type: String, required: true })
name: string;
/**
* Converts bot statistics data into an line chart data format.
*
* @param stats - The array of bot statistics.
* @param types - The array of bot statistics types.
* @returns An array of data representing the bot statistics data.
*/
static toLines(stats: BotStats[], types: BotStatsType[]): ToLinesType[] {
const data = types.map((type, index) => {
return {
id: index + 1,
name: type,
values: [],
};
});
const index: { [dataName: string]: number } = data.reduce(
(acc, curr, i) => {
acc[curr.name] = i;
return acc;
},
{},
);
const result = stats.reduce((acc, stat: BotStats & { date: Date }) => {
stat.date = stat.day;
acc[index[stat.type]].values.push(stat);
return acc;
}, data);
return result;
}
/**
* Converts fetched stats to a bar chart compatible data format
*
* @param stats - Array of objects, each contaning at least an id and a value
* @returns BarChart compatible data
*/
static toBars(
stats: { id: string; value: number }[],
): { id: string; name: string; value: number }[] {
return stats.map((stat) => {
return {
...stat,
name: stat.id,
};
});
}
}
export type BotStatsDocument = THydratedDocument<BotStats>;
export const BotStatsModel: ModelDefinition = {
name: BotStats.name,
schema: SchemaFactory.createForClass(BotStats),
};
export default BotStatsModel.schema;

View File

@@ -0,0 +1,122 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import {
botstatsFixtures,
installBotStatsFixtures,
} from '@/utils/test/fixtures/botstats';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { BotStatsService } from './bot-stats.service';
import { BotStatsRepository } from '../repositories/bot-stats.repository';
import { BotStatsModel, BotStatsType } from '../schemas/bot-stats.schema';
describe('BotStatsService', () => {
let botStatsService: BotStatsService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installBotStatsFixtures),
MongooseModule.forFeature([BotStatsModel]),
],
providers: [
LoggerService,
BotStatsService,
BotStatsRepository,
EventEmitter2,
],
}).compile();
botStatsService = module.get<BotStatsService>(BotStatsService);
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('findMessages', () => {
it('should return all messages', async () => {
const from = botstatsFixtures[0].day;
const to = new Date();
const result = await botStatsService.findMessages(
from,
to,
Object.values(BotStatsType),
);
expect(result).toEqualPayload(botstatsFixtures);
});
it('should return messages between the given date range', async () => {
const from = botstatsFixtures[0].day;
const to = botstatsFixtures[2].day;
const result = await botStatsService.findMessages(
from,
to,
Object.values(BotStatsType),
);
expect(result).toEqualPayload(botstatsFixtures.slice(0, 3));
});
it('should return messages of a given type', async () => {
const from = botstatsFixtures[0].day;
const to = new Date();
const result = await botStatsService.findMessages(from, to, [
BotStatsType.outgoing,
]);
expect(result).toEqualPayload([botstatsFixtures[5]]);
});
it('should return messages of type conversation', async () => {
const from = botstatsFixtures[0].day;
const to = new Date();
const result = await botStatsService.findMessages(from, to, [
BotStatsType.new_conversations,
BotStatsType.existing_conversations,
]);
expect(result).toEqualPayload([botstatsFixtures[3]]);
});
it('should return messages of type audiance', async () => {
const from = botstatsFixtures[0].day;
const to = new Date();
const result = await botStatsService.findMessages(from, to, [
BotStatsType.new_users,
BotStatsType.returning_users,
BotStatsType.retention,
]);
expect(result).toEqualPayload([botstatsFixtures[1]]);
});
});
describe('findPopularBlocks', () => {
it('should return popular blocks', async () => {
const from = botstatsFixtures[0].day;
const to = new Date();
const result = await botStatsService.findPopularBlocks(from, to);
expect(result).toEqual([
{
id: 'Global Fallback',
value: 68,
},
]);
});
});
});

View File

@@ -0,0 +1,136 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Subscriber } from '@/chat/schemas/subscriber.schema';
import { config } from '@/config';
import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service';
import { BotStatsRepository } from '../repositories/bot-stats.repository';
import { BotStats, BotStatsType } from '../schemas/bot-stats.schema';
@Injectable()
export class BotStatsService extends BaseService<BotStats> {
constructor(
readonly repository: BotStatsRepository,
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService,
) {
super(repository);
}
/**
* Retrieves statistics for messages within a specified time range and of specified types.
*
* @param from - The start date for filtering messages.
* @param to - The end date for filtering messages.
* @param types - An array of message types (of type BotStatsType) to filter the statistics.
*
* @returns A promise that resolves to an array of `BotStats` objects representing the message statistics.
*/
async findMessages(
from: Date,
to: Date,
types: BotStatsType[],
): Promise<BotStats[]> {
return await this.repository.findMessages(from, to, types);
}
/**
* Retrieves the most popular blocks within a specified time range.
* Popular blocks are those triggered the most frequently.
*
* @param from - The start date of the time range.
* @param to - The end date of the time range.
* @returns A promise that resolves with an array of popular blocks, each containing an `id` and the number of times it was triggered (`value`).
*/
async findPopularBlocks(
from: Date,
to: Date,
): Promise<{ id: string; value: number }[]> {
return await this.repository.findPopularBlocks(from, to);
}
/**
* Handles the event to track user activity and emit statistics for loyalty, returning users, and retention.
*
* This method checks the last visit of the subscriber and emits relevant analytics events
* based on configured thresholds for loyalty, returning users, and retention.
*
* @param {Subscriber} subscriber - The subscriber object that contains last visit and retention data.
*/
@OnEvent('hook:user:lastvisit')
private handleLastVisit(subscriber: Subscriber) {
const now = +new Date();
if (subscriber.lastvisit) {
// A loyal subscriber is a subscriber that comes back after some inactivity
if (now - +subscriber.lastvisit > config.analytics.thresholds.loyalty) {
this.eventEmitter.emit(
'hook:stats:entry',
'returning_users',
'Loyalty',
subscriber,
);
}
// Returning subscriber is a subscriber that comes back after some inactivity
if (now - +subscriber.lastvisit > config.analytics.thresholds.returning) {
this.eventEmitter.emit(
'hook:stats:entry',
'returning_users',
'Returning users',
);
}
}
// Retention
if (
subscriber.retainedFrom &&
now - +subscriber.retainedFrom > config.analytics.thresholds.retention
) {
this.eventEmitter.emit(
'hook:stats:entry',
'retention',
'Retentioned users',
);
}
}
/**
* Handles the event to update bot statistics.
*
* @param type - The type of bot statistics being tracked (e.g., user messages, bot responses).
* @param name - The name or identifier of the statistics entry (e.g., a specific feature or component being tracked).
*/
@OnEvent('hook:stats:entry')
private async handleStatEntry(type: BotStatsType, name: string) {
const day = new Date();
day.setMilliseconds(0);
day.setSeconds(0);
day.setMinutes(0);
day.setHours(0);
try {
const insight = await this.findOneOrCreate(
{ day: { $lte: day, $gte: day }, type, name },
{ day, type, name, value: 0 },
);
try {
await this.updateOne(insight.id, { value: insight.value + 1 });
} catch (err) {
this.logger.error('Stats hook : Unable to update insight', err);
}
} catch (err) {
this.logger.error('Stats hook : Unable to find or create insight', err);
}
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
export const aMonthAgo = (): Date =>
new Date(new Date().setMonth(new Date().getMonth() - 1));

View File

@@ -0,0 +1,38 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
export function IsLessThanDate(
property: string,
validationOptions?: ValidationOptions,
) {
return (object: unknown, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = args.object[relatedPropertyName];
if (relatedValue) {
return value <= relatedValue;
}
return true;
},
},
});
};
}

73
api/src/app.controller.ts Normal file
View File

@@ -0,0 +1,73 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
BadRequestException,
Controller,
Get,
Req,
Res,
Session,
} from '@nestjs/common';
import { CsrfCheck, CsrfGenAuth } from '@tekuconcept/nestjs-csrf';
import { CsrfGenerator } from '@tekuconcept/nestjs-csrf/dist/csrf.generator';
import { Request, Response } from 'express';
import { Session as ExpressSession } from 'express-session';
import { AppService } from './app.service';
import { config } from './config';
import { LoggerService } from './logger/logger.service';
import { Roles } from './utils/decorators/roles.decorator';
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
private readonly logger: LoggerService,
) {}
@Roles('public')
@Get()
getHello(): string {
return this.appService.getHello();
}
@Roles('public')
@Get('csrftoken')
@CsrfCheck(false)
@CsrfGenAuth(true)
csrf(@Session() session: ExpressSession) {
return {
_csrf: session?.csrfSecret
? new CsrfGenerator().create(session.csrfSecret)
: '',
};
}
@Roles('public')
@Get('__getcookie')
cookies(@Req() req: Request): string {
req.session.anonymous = true;
return '_sailsIoJSConnect();';
}
// @TODO : remove once old frontend is abandoned
@Get('logout')
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
res.clearCookie(config.session.name);
req.session.destroy((error) => {
if (error) {
this.logger.error(error);
throw new BadRequestException();
}
});
return { status: 'ok' };
}
}

139
api/src/app.module.ts Normal file
View File

@@ -0,0 +1,139 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import path from 'path';
import { CacheModule } from '@nestjs/cache-manager';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { MailerModule } from '@nestjs-modules/mailer';
import { MjmlAdapter } from '@nestjs-modules/mailer/dist/adapters/mjml.adapter';
import { CsrfGuard, CsrfModule } from '@tekuconcept/nestjs-csrf';
import {
AcceptLanguageResolver,
I18nOptions,
QueryResolver,
} from 'nestjs-i18n';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { AnalyticsModule } from './analytics/analytics.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AttachmentModule } from './attachment/attachment.module';
import { ChannelModule } from './channel/channel.module';
import { ChatModule } from './chat/chat.module';
import { CmsModule } from './cms/cms.module';
import { config } from './config';
import { ExtendedI18nModule } from './extended-18n.module';
import { LoggerModule } from './logger/logger.module';
import { DtoUpdateMiddleware } from './middlewares/dto.update.middleware';
import { NlpModule } from './nlp/nlp.module';
import { PluginsModule } from './plugins/plugins.module';
import { SettingModule } from './setting/setting.module';
import { Ability } from './user/guards/ability.guard';
import { UserModule } from './user/user.module';
import idPlugin from './utils/schema-plugin/id.plugin';
import { WebsocketModule } from './websocket/websocket.module';
const i18nOptions: I18nOptions = {
fallbackLanguage: config.chatbot.lang.default,
loaderOptions: {
path: path.join(__dirname, '/config/i18n/'),
watch: true,
},
resolvers: [
{ use: QueryResolver, options: ['lang'] },
AcceptLanguageResolver,
],
};
@Module({
imports: [
MailerModule.forRoot({
transport: new SMTPTransport({
...config.emails.smtp,
logger: true,
}),
template: {
adapter: new MjmlAdapter('ejs', { inlineCssEnabled: false }),
dir: './src/templates',
options: {
context: {
appName: config.parameters.appName,
appUrl: config.parameters.appUrl,
// TODO: add i18n support
},
},
},
defaults: { from: config.parameters.email.main },
}),
MongooseModule.forRoot(config.mongo.uri, {
dbName: config.mongo.dbName,
connectionFactory: (connection) => {
connection.plugin(idPlugin);
// eslint-disable-next-line @typescript-eslint/no-var-requires
connection.plugin(require('mongoose-lean-virtuals'));
// eslint-disable-next-line @typescript-eslint/no-var-requires
connection.plugin(require('mongoose-lean-getters'));
// eslint-disable-next-line @typescript-eslint/no-var-requires
connection.plugin(require('mongoose-lean-defaults').default);
return connection;
},
}),
NlpModule,
CmsModule,
UserModule,
SettingModule,
AttachmentModule,
AnalyticsModule,
ChatModule,
ChannelModule,
PluginsModule,
LoggerModule,
WebsocketModule,
EventEmitterModule.forRoot({
// set this to `true` to use wildcards
wildcard: true,
// the delimiter used to segment namespaces
delimiter: ':',
// set this to `true` if you want to emit the newListener event
newListener: false,
// set this to `true` if you want to emit the removeListener event
removeListener: false,
// the maximum amount of listeners that can be assigned to an event
maxListeners: 10,
// show event name in memory leak message when more than maximum amount of listeners is assigned
verboseMemoryLeak: false,
// disable throwing uncaughtException if an error event is emitted and it has no listeners
ignoreErrors: false,
}),
CsrfModule,
ExtendedI18nModule.forRoot(i18nOptions),
CacheModule.register({
isGlobal: true,
ttl: config.cache.ttl,
max: config.cache.max,
}),
],
controllers: [AppController],
providers: [
{ provide: APP_GUARD, useClass: Ability },
{ provide: APP_GUARD, useClass: CsrfGuard },
AppService,
],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(DtoUpdateMiddleware)
.forRoutes({ path: '*', method: RequestMethod.PATCH });
}
}

26
api/src/app.service.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ExtendedI18nService } from './extended-i18n.service';
@Injectable()
export class AppService {
constructor(
private readonly i18n: ExtendedI18nService,
private readonly eventEmitter: EventEmitter2,
) {}
getHello(): string {
this.eventEmitter.emit('hook:i18n:refresh', []);
return this.i18n.t('Welcome');
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport';
import { AttachmentController } from './controllers/attachment.controller';
import { AttachmentRepository } from './repositories/attachment.repository';
import { AttachmentModel } from './schemas/attachment.schema';
import { AttachmentService } from './services/attachment.service';
@Module({
imports: [
MongooseModule.forFeature([AttachmentModel]),
PassportModule.register({
session: true,
}),
],
providers: [AttachmentRepository, AttachmentService],
controllers: [AttachmentController],
exports: [AttachmentService],
})
export class AttachmentModule {}

View File

@@ -0,0 +1,166 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { BadRequestException } from '@nestjs/common/exceptions';
import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
attachmentFixtures,
installAttachmentFixtures,
} from '@/utils/test/fixtures/attachment';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { AttachmentController } from './attachment.controller';
import { attachment, attachmentFile } from '../mocks/attachment.mock';
import { AttachmentRepository } from '../repositories/attachment.repository';
import { AttachmentModel, Attachment } from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service';
describe('AttachmentController', () => {
let attachmentController: AttachmentController;
let attachmentService: AttachmentService;
let attachmentToDelete: Attachment;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttachmentController],
imports: [
rootMongooseTestModule(installAttachmentFixtures),
MongooseModule.forFeature([AttachmentModel]),
],
providers: [
AttachmentService,
AttachmentRepository,
LoggerService,
EventEmitter2,
PluginService,
],
}).compile();
attachmentController =
module.get<AttachmentController>(AttachmentController);
attachmentService = module.get<AttachmentService>(AttachmentService);
attachmentToDelete = await attachmentService.findOne({
name: 'store1.jpg',
});
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('count', () => {
it('should count attachments', async () => {
jest.spyOn(attachmentService, 'count');
const result = await attachmentController.filterCount();
expect(attachmentService.count).toHaveBeenCalled();
expect(result).toEqual({ count: attachmentFixtures.length });
});
});
describe('Upload', () => {
it('should throw BadRequestException if no file is selected to be uploaded', async () => {
const promiseResult = attachmentController.uploadFile({
file: undefined,
});
await expect(promiseResult).rejects.toThrow(
new BadRequestException('No file was selected'),
);
});
it('should upload attachment', async () => {
jest.spyOn(attachmentService, 'create');
const result = await attachmentController.uploadFile({
file: [attachmentFile],
});
expect(attachmentService.create).toHaveBeenCalledWith({
size: attachmentFile.size,
type: attachmentFile.mimetype,
name: attachmentFile.filename,
channel: {},
location: `/${attachmentFile.filename}`,
});
expect(result).toEqualPayload(
[attachment],
[...IGNORED_TEST_FIELDS, 'url'],
);
});
});
describe('Download', () => {
it(`should throw NotFoundException the id or/and file don't exist`, async () => {
jest.spyOn(attachmentService, 'findOne');
const result = attachmentController.download({ id: NOT_FOUND_ID });
expect(attachmentService.findOne).toHaveBeenCalledWith(NOT_FOUND_ID);
expect(result).rejects.toThrow(
new NotFoundException('Attachment not found'),
);
});
it('should download the attachment by id', async () => {
jest.spyOn(attachmentService, 'findOne');
const storedAttachment = await attachmentService.findOne({
name: 'store1.jpg',
});
const result = await attachmentController.download({
id: storedAttachment.id,
});
expect(attachmentService.findOne).toHaveBeenCalledWith(
storedAttachment.id,
);
expect(result.options).toEqual({
type: storedAttachment.type,
length: storedAttachment.size,
disposition: `attachment; filename="${encodeURIComponent(
storedAttachment.name,
)}"`,
});
});
});
describe('deleteOne', () => {
it('should delete an attachment by id', async () => {
jest.spyOn(attachmentService, 'deleteOne');
const result = await attachmentController.deleteOne(
attachmentToDelete.id,
);
expect(attachmentService.deleteOne).toHaveBeenCalledWith(
attachmentToDelete.id,
);
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
});
it('should throw a NotFoundException when attempting to delete an attachment by id', async () => {
await expect(
attachmentController.deleteOne(attachmentToDelete.id),
).rejects.toThrow(
new NotFoundException(
`Attachment with ID ${attachmentToDelete.id} not found`,
),
);
});
});
});

View File

@@ -0,0 +1,177 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { extname } from 'path';
import {
BadRequestException,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Post,
Query,
StreamableFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { diskStorage, memoryStorage } from 'multer';
import { v4 as uuidv4 } from 'uuid';
import { config } from '@/config';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { Roles } from '@/utils/decorators/roles.decorator';
import { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { AttachmentDownloadDto } from '../dto/attachment.dto';
import { Attachment } from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service';
@UseInterceptors(CsrfInterceptor)
@Controller('attachment')
export class AttachmentController extends BaseController<Attachment> {
constructor(
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
) {
super(attachmentService);
}
/**
* Counts the filtered number of attachments.
*
* @returns A promise that resolves to an object representing the filtered number of attachments.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Attachment>({
allowedFields: ['name', 'type'],
}),
)
filters?: TFilterQuery<Attachment>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<Attachment> {
const doc = await this.attachmentService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Attachement by id ${id}`);
throw new NotFoundException(`Attachement with ID ${id} not found`);
}
return doc;
}
/**
* Retrieves all attachments based on specified filters.
*
* @param pageQuery - The pagination to apply when retrieving attachments.
* @param filters - The filters to apply when retrieving attachments.
* @returns A promise that resolves to an array of attachments matching the filters.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Attachment>,
@Query(
new SearchFilterPipe<Attachment>({ allowedFields: ['name', 'type'] }),
)
filters: TFilterQuery<Attachment>,
) {
return await this.attachmentService.findPage(filters, pageQuery);
}
/**
* Uploads files to the server.
*
* @param files - An array of files to upload.
* @returns A promise that resolves to an array of uploaded attachments.
*/
@CsrfCheck(true)
@Post('upload')
@UseInterceptors(
FileFieldsInterceptor([{ name: 'file' }], {
limits: {
fileSize: config.parameters.maxUploadSize,
},
storage: (() => {
if (config.parameters.storageMode === 'memory') {
return memoryStorage();
} else {
return diskStorage({
destination: config.parameters.uploadDir,
filename: (req, file, cb) => {
const name = file.originalname.split('.')[0];
const extension = extname(file.originalname);
cb(null, `${name}-${uuidv4()}${extension}`);
},
});
}
})(),
}),
)
async uploadFile(
@UploadedFiles() files: { file: Express.Multer.File[] },
): Promise<Attachment[]> {
if (!files || !Array.isArray(files?.file) || files.file.length === 0) {
throw new BadRequestException('No file was selected');
}
return await this.attachmentService.uploadFiles(files);
}
/**
* Downloads an attachment identified by the provided parameters.
*
* @param params - The parameters identifying the attachment to download.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
*/
@Roles('public')
@Get('download/:id/:filename?')
async download(
@Param() params: AttachmentDownloadDto,
): Promise<StreamableFile> {
const attachment = await this.attachmentService.findOne(params.id);
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
return this.attachmentService.download(attachment);
}
/**
* Deletes an attachment with the specified ID.
*
* @param id - The ID of the attachment to delete.
* @returns A promise that resolves to the result of the deletion operation.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.attachmentService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete attachment by id ${id}`);
throw new NotFoundException(`Attachment with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsObject,
IsOptional,
MaxLength,
IsNotEmpty,
IsString,
} from 'class-validator';
import { ObjectIdDto } from '@/utils/dto/object-id.dto';
export class AttachmentCreateDto {
/**
* Attachment channel
*/
@ApiPropertyOptional({ description: 'Attachment channel', type: Object })
@IsNotEmpty()
@IsObject()
channel?: Partial<Record<string, any>>;
/**
* Attachment location
*/
@ApiProperty({ description: 'Attachment location', type: String })
@IsNotEmpty()
@IsString()
location: string;
/**
* Attachment name
*/
@ApiProperty({ description: 'Attachment name', type: String })
@IsNotEmpty()
@IsString()
name: string;
/**
* Attachment size
*/
@ApiProperty({ description: 'Attachment size', type: Number })
@IsNotEmpty()
size: number;
/**
* Attachment type
*/
@ApiProperty({ description: 'Attachment type', type: String })
@IsNotEmpty()
@IsString()
type: string;
}
export class AttachmentDownloadDto extends ObjectIdDto {
/**
* Attachment file name
*/
@ApiPropertyOptional({
description: 'Attachment download filename',
type: String,
})
@Type(() => String)
@MaxLength(255)
@IsOptional()
filename?: string;
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Stream } from 'node:stream';
import { Attachment } from '../schemas/attachment.schema';
export const attachment: Attachment = {
name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
type: 'image/png',
size: 343370,
location:
'/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
id: '65940d115178607da65c82b6',
createdAt: new Date(),
updatedAt: new Date(),
};
export const attachmentFile: Express.Multer.File = {
filename: attachment.name,
mimetype: attachment.type,
size: attachment.size,
buffer: Buffer.from(new Uint8Array([])),
destination: '',
fieldname: '',
originalname: '',
path: '',
stream: new Stream.Readable(),
encoding: '7bit',
};
export const attachments: Attachment[] = [
attachment,
{
name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
type: 'image/png',
size: 343370,
location:
'/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
channel: { dimelo: {} },
id: '65940d115178607da65c82b7',
createdAt: new Date(),
updatedAt: new Date(),
},
{
name: 'Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
type: 'image/png',
size: 33829,
location:
'/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
channel: { dimelo: {} },
id: '65940d115178607da65c82b8',
createdAt: new Date(),
updatedAt: new Date(),
},
];

View File

@@ -0,0 +1,36 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { Attachment, AttachmentDocument } from '../schemas/attachment.schema';
@Injectable()
export class AttachmentRepository extends BaseRepository<Attachment, never> {
constructor(
@InjectModel(Attachment.name) readonly model: Model<Attachment>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Attachment);
}
/**
* Handles post-creation operations for an attachment.
*
* @param created - The created attachment document.
*/
async postCreate(created: AttachmentDocument): Promise<void> {
this.eventEmitter.emit('hook:chatbot:attachment:upload', created);
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { THydratedDocument } from 'mongoose';
import { FileType } from '@/chat/schemas/types/attachment';
import { config } from '@/config';
import { BaseSchema } from '@/utils/generics/base-schema';
import { MIME_REGEX } from '../utilities';
// TODO: Interface AttachmentAttrs declared, currently not used
export interface AttachmentAttrs {
name: string;
type: string;
size: number;
location: string;
channel?: Record<string, any>;
}
@Schema({ timestamps: true })
export class Attachment extends BaseSchema {
/**
* The name of the attachment.
*/
@Prop({
type: String,
required: true,
})
name: string;
/**
* The MIME type of the attachment, must match the MIME_REGEX.
*/
@Prop({
type: String,
required: true,
match: MIME_REGEX,
})
type: string;
/**
* The size of the attachment in bytes, must be between 0 and config.parameters.maxUploadSize.
*/
@Prop({
type: Number,
required: true,
min: 0,
max: config.parameters.maxUploadSize,
})
size: number;
/**
* The location of the attachment, must be a unique value and pass the fileExists validation.
*/
@Prop({
type: String,
unique: true,
})
location: string;
/**
* Optional property representing the attachment channel, can hold a partial record of various channel data.
*/
@Prop({ type: JSON })
channel?: Partial<Record<string, any>>;
/**
* Optional property representing the URL of the attachment.
*
*/
url?: string;
/**
* Generates and returns the URL of the attachment.
* @param attachmentId - Id of the attachment
* @param attachmentName - The file name of the attachment. Optional and defaults to an empty string.
* @returns A string representing the attachment URL
*/
static getAttachmentUrl(
attachmentId: string,
attachmentName: string = '',
): string {
return `${config.parameters.apiUrl}/attachment/download/${attachmentId}/${attachmentName}`;
}
/**
* Determines the type of the attachment based on its MIME type.
* @param mimeType - The MIME Type of the attachment (eg. image/png)
* @returns The attachment type ('image', 'audio', 'video' or 'file')
*/
static getTypeByMime(mimeType: string): FileType {
if (mimeType.startsWith(FileType.image)) {
return FileType.image;
} else if (mimeType.startsWith(FileType.audio)) {
return FileType.audio;
} else if (mimeType.startsWith(FileType.video)) {
return FileType.video;
} else {
return FileType.file;
}
}
}
export type AttachmentDocument = THydratedDocument<Attachment>;
export const AttachmentModel: ModelDefinition = {
name: Attachment.name,
schema: SchemaFactory.createForClass(Attachment),
};
AttachmentModel.schema.virtual('url').get(function () {
if (this._id && this.name)
return `${config.apiPath}/attachment/download/${this._id}/${this.name}`;
return '';
});
export default AttachmentModel.schema;

View File

@@ -0,0 +1,212 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import fs, { createReadStream } from 'fs';
import path, { join } from 'path';
import {
Injectable,
NotFoundException,
Optional,
StreamableFile,
} from '@nestjs/common';
import fetch from 'node-fetch';
import { config } from '@/config';
import { LoggerService } from '@/logger/logger.service';
import { PluginInstance } from '@/plugins/map-types';
import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { BaseService } from '@/utils/generics/base-service';
import { AttachmentRepository } from '../repositories/attachment.repository';
import { Attachment } from '../schemas/attachment.schema';
import { fileExists, getStreamableFile } from '../utilities';
@Injectable()
export class AttachmentService extends BaseService<Attachment> {
private storagePlugin: PluginInstance<PluginType.storage> | null = null;
constructor(
readonly repository: AttachmentRepository,
private readonly logger: LoggerService,
@Optional() private readonly pluginService: PluginService,
) {
super(repository);
}
/**
* A storage plugin is a alternative way to store files, instead of local filesystem, you can
* have a plugin that would store files in a 3rd party system (Minio, AWS S3, ...)
*
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
* @returns A singleton instance of the storage plugin
*/
getStoragePlugin() {
if (!this.storagePlugin) {
const plugins = this.pluginService.getAllByType(PluginType.storage);
if (plugins.length === 1) {
this.storagePlugin = plugins[0];
} else if (plugins.length > 1) {
throw new Error(
'Multiple storage plugins are detected, please ensure only one is available',
);
}
}
return this.storagePlugin;
}
/**
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration.
*
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
* @returns A `StreamableFile` containing the user's profile picture.
*/
async downloadProfilePic(foreign_id: string): Promise<StreamableFile> {
if (this.getStoragePlugin()) {
try {
const pict = foreign_id + '.jpeg';
const picture = await this.getStoragePlugin().downloadProfilePic(pict);
return picture;
} catch (err) {
this.logger.error('Error downloading profile picture', err);
throw new NotFoundException('Profile picture not found');
}
} else {
const path = join(config.parameters.avatarDir, `${foreign_id}.jpeg`);
if (fs.existsSync(path)) {
const picturetream = createReadStream(path);
return new StreamableFile(picturetream);
} else {
throw new NotFoundException('Profile picture not found');
}
}
}
/**
* Uploads a profile picture to either 3rd party storage system or locally based on the configuration.
*
* @param res - The response object from which the profile picture will be buffered or piped.
* @param filename - The filename
*/
async uploadProfilePic(res: fetch.Response, filename: string) {
if (this.getStoragePlugin()) {
// Upload profile picture
const buffer = await res.buffer();
const picture = {
originalname: filename,
buffer,
} as Express.Multer.File;
try {
await this.getStoragePlugin().uploadAvatar(picture);
this.logger.log(
`Profile picture uploaded successfully to ${
this.getStoragePlugin().id
}`,
);
} catch (err) {
this.logger.error(
`Error while uploading profile picture to ${
this.getStoragePlugin().id
}`,
err,
);
}
} else {
// Save profile picture locally
const dirPath = path.join(config.parameters.avatarDir, filename);
try {
await fs.promises.mkdir(config.parameters.avatarDir, {
recursive: true,
}); // Ensure the directory exists
const dest = fs.createWriteStream(dirPath);
res.body.pipe(dest);
this.logger.debug(
'Messenger Channel Handler : Profile picture fetched successfully',
);
} catch (err) {
this.logger.error(
'Messenger Channel Handler : Error while creating directory',
err,
);
}
}
}
/**
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
* Otherwise, uploads files to the local directory.
*
* @param files - An array of files to upload.
* @returns A promise that resolves to an array of uploaded attachments.
*/
async uploadFiles(files: { file: Express.Multer.File[] }) {
if (this.getStoragePlugin()) {
const dtos = await Promise.all(
files.file.map((file) => {
return this.getStoragePlugin().upload(file);
}),
);
const uploadedFiles = await Promise.all(
dtos.map((dto) => {
return this.create(dto);
}),
);
return uploadedFiles;
} else {
if (Array.isArray(files?.file)) {
const uploadedFiles = await Promise.all(
files?.file?.map(async ({ size, filename, mimetype }) => {
return await this.create({
size,
type: mimetype,
name: filename,
channel: {},
location: `/${filename}`,
});
}),
);
return uploadedFiles;
}
}
}
/**
* Downloads an attachment identified by the provided parameters.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
*/
async download(attachment: Attachment) {
if (this.getStoragePlugin()) {
return await this.getStoragePlugin().download(attachment);
} else {
if (!fileExists(attachment.location)) {
throw new NotFoundException('No file was found');
}
const path = join(config.parameters.uploadDir, attachment.location);
const disposition = `attachment; filename="${encodeURIComponent(
attachment.name,
)}"`;
return getStreamableFile({
path,
options: {
type: attachment.type,
length: attachment.size,
disposition,
},
});
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { createReadStream, existsSync } from 'fs';
import { join } from 'path';
import { Logger, StreamableFile } from '@nestjs/common';
import { StreamableFileOptions } from '@nestjs/common/file-stream/interfaces/streamable-options.interface';
import { config } from '@/config';
export const MIME_REGEX = /^[a-z-]+\/[0-9a-z\-.]+$/gm;
export const isMime = (type: string): boolean => {
return MIME_REGEX.test(type);
};
export const fileExists = (location: string): boolean => {
// bypass test env
if (config.env === 'test') {
return true;
}
try {
const dirPath = config.parameters.uploadDir;
const fileLocation = join(dirPath, location);
return existsSync(fileLocation);
} catch (e) {
new Logger(`Attachment Model : Unable to locate file: ${location}`);
return false;
}
};
export const getStreamableFile = ({
path,
options,
}: {
path: string;
options?: StreamableFileOptions;
}) => {
// bypass test env
if (config.env === 'test') {
return new StreamableFile(Buffer.from(''), options);
}
const fileReadStream = createReadStream(path);
return new StreamableFile(fileReadStream, options);
};

View File

@@ -0,0 +1,31 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Controller, Get } from '@nestjs/common';
import { ChannelService } from './channel.service';
@Controller('channel')
export class ChannelController {
constructor(private readonly channelService: ChannelService) {}
/**
* Retrieves the list of channels.
*
* @returns An array of objects where each object represents a channel with a `name` property.
*/
@Get()
getChannels(): { name: string }[] {
return this.channelService.getAll().map((handler) => {
return {
name: handler.getChannel(),
};
});
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { ChannelService } from './channel.service';
@Injectable()
export class ChannelMiddleware implements NestMiddleware {
constructor(private readonly channelService: ChannelService) {}
async use(req: Request, res: Response, next: NextFunction) {
// Iterate through channel handlers to execute a certain middleware if needed
try {
const [_, path, channelName] = req.path.split('/');
if (path === 'webhook' && channelName) {
const channel = this.channelService.getChannelHandler(channelName);
if (channel) {
return await channel.middleware(req, res, next);
}
}
next();
} catch (err) {
next(new Error(`Unable to execute middleware on route ${req.path}`));
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { InjectDynamicProviders } from 'nestjs-dynamic-providers';
import { AttachmentModule } from '@/attachment/attachment.module';
import { ChatModule } from '@/chat/chat.module';
import { CmsModule } from '@/cms/cms.module';
import { NlpModule } from '@/nlp/nlp.module';
import { ChannelController } from './channel.controller';
import { ChannelMiddleware } from './channel.middleware';
import { ChannelService } from './channel.service';
import { WebhookController } from './webhook.controller';
export interface ChannelModuleOptions {
folder: string;
}
@InjectDynamicProviders('dist/**/*.channel.js')
@Module({
controllers: [WebhookController, ChannelController],
providers: [ChannelService],
exports: [ChannelService],
imports: [NlpModule, ChatModule, AttachmentModule, CmsModule],
})
export class ChannelModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ChannelMiddleware)
.forRoutes({ path: 'webhook/*', method: RequestMethod.ALL });
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Request, Response } from 'express';
import { SubscriberService } from '@/chat/services/subscriber.service';
import { LIVE_CHAT_TEST_CHANNEL_NAME } from '@/extensions/channels/live-chat-tester/settings';
import { OFFLINE_CHANNEL_NAME } from '@/extensions/channels/offline/settings';
import { LoggerService } from '@/logger/logger.service';
import {
SocketGet,
SocketPost,
} from '@/websocket/decorators/socket-method.decorator';
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
import ChannelHandler from './lib/Handler';
@Injectable()
export class ChannelService {
private registry: Map<string, ChannelHandler> = new Map();
constructor(
private readonly logger: LoggerService,
private readonly subscriberService: SubscriberService,
) {}
/**
* Registers a channel with a specific handler.
*
* @param name - The name of the channel to be registered.
* @param channel - The channel handler associated with the channel name.
* @typeParam C The channel handler's type that extends `ChannelHandler`.
*/
public setChannel<C extends ChannelHandler>(name: string, channel: C) {
this.registry.set(name, channel);
}
/**
* Retrieves all registered channel handlers.
*
* @returns An array of all channel handlers currently registered.
*/
public getAll() {
return Array.from(this.registry.values());
}
/**
* Finds and returns the channel handler associated with the specified channel name.
*
* @param name - The name of the channel to find.
* @returns The channel handler associated with the specified name, or undefined if the channel is not found.
*/
public findChannel(name: string) {
return this.getAll().find((c) => {
return c.getChannel() === name;
});
}
/**
* Retrieves the appropriate channel handler based on the channel name.
*
* @param channelName - The name of the channel (messenger, offline, ...).
* @returns The handler for the specified channel.
*/
public getChannelHandler<C extends ChannelHandler>(name: string): C {
const handler = this.registry.get(name);
if (!handler) {
throw new Error(`Channel ${name} not found`);
}
return handler as C;
}
/**
* Handles a request for a specific channel.
*
* @param channel - The channel for which the request is being handled.
* @param req - The HTTP request object.
* @param res - The HTTP response object.
* @returns A promise that resolves when the handler has processed the request.
*/
async handle(channel: string, req: Request, res: Response): Promise<void> {
const handler = this.getChannelHandler(channel);
handler.handle(req, res);
}
/**
* Handles a websocket request for the offline channel.
*
* @param req - The websocket request object.
* @param res - The websocket response object.
*/
@SocketGet('/webhook/offline/')
@SocketPost('/webhook/offline/')
handleWebsocketForOffline(
@SocketReq() req: SocketRequest,
@SocketRes() res: SocketResponse,
) {
this.logger.log('Channel notification (Offline Socket) : ', req.method);
const handler = this.getChannelHandler(OFFLINE_CHANNEL_NAME);
return handler.handle(req, res);
}
/**
* Handles a websocket request for the live chat tester channel.
* It considers the user as a subscriber.
*
* @param req - The websocket request object.
* @param res - The websocket response object.
*/
@SocketGet('/webhook/live-chat-tester/')
@SocketPost('/webhook/live-chat-tester/')
async handleWebsocketForLiveChatTester(
@SocketReq() req: SocketRequest,
@SocketRes() res: SocketResponse,
) {
this.logger.log(
'Channel notification (Live Chat Tester Socket) : ',
req.method,
);
if (!req.session?.passport?.user?.id) {
throw new UnauthorizedException(
'Only authenticated users are allowed to use this channel',
);
}
// Create test subscriber for the current user
const testSubscriber = await this.subscriberService.findOneOrCreate(
{
foreign_id: req.session.passport.user.id,
},
{
id: req.session.passport.user.id,
foreign_id: req.session.passport.user.id,
first_name: req.session.passport.user.first_name,
last_name: req.session.passport.user.last_name,
locale: '',
language: '',
gender: '',
country: '',
labels: [],
channel: {
name: LIVE_CHAT_TEST_CHANNEL_NAME,
isSocket: true,
},
},
);
// Update session (end user is both a user + subscriber)
req.session.offline = {
profile: testSubscriber,
isSocket: true,
messageQueue: [],
polling: false,
};
const handler = this.getChannelHandler(LIVE_CHAT_TEST_CHANNEL_NAME);
return handler.handle(req, res);
}
}

View File

@@ -0,0 +1,392 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Subscriber } from '@/chat/schemas/subscriber.schema';
import {
AttachmentForeignKey,
AttachmentPayload,
} from '@/chat/schemas/types/attachment';
import {
IncomingMessageType,
StdEventType,
StdIncomingMessage,
} from '@/chat/schemas/types/message';
import { Payload } from '@/chat/schemas/types/quick-reply';
import { Nlp } from '@/nlp/lib/types';
import ChannelHandler from './Handler';
export interface ChannelEvent {}
export default abstract class EventWrapper<
A,
E,
C extends ChannelHandler = ChannelHandler,
> {
_adapter: A = {} as A;
_handler: C;
_profile!: Subscriber;
_nlp!: Nlp.ParseEntities;
/**
* Constructor : Class used to wrap any channel's event in order
* to provide a unified interface for accessing data by the chatbot.
*
* Any method declared in this class should be extended and overridden in any given channel's
* event wrapper if needed.
* @param handler - The channel's handler
* @param event - The message event received
* @param channelData - Channel's specific data
*/
constructor(handler: C, event: E, channelData: any = {}) {
this._handler = handler;
this._init(event);
this.set('channelData', channelData);
}
toString() {
return JSON.stringify(
{
handler: this._handler.getChannel(),
channelData: this.getChannelData(),
sender: this.getSender(),
recipient: this.getRecipientForeignId(),
eventType: this.getEventType(),
messageType: this.getMessageType(),
payload: this.getPayload(),
message: this.getMessage(),
attachments: this.getAttachments(),
deliveredMessages: this.getDeliveredMessages(),
watermark: this.getWatermark(),
},
null,
4,
);
}
/**
* Called by the parent constructor, it defines `_adapter` which should store :
*
* - `_adapter.eventType` : The type of event received
*
*- `_adapter.messageType` : The type of message when the event is a message.
*
*- `_adapter.raw` : Sets a typed object of the event raw data
* @param event - The message event received from a given channel
*/
abstract _init(event: E): void;
/**
* Retrieves the current channel handler.
*
* @returns The current instance of the channel handler.
*/
getHandler(): ChannelHandler {
return this._handler;
}
/**
* Retrieves channel data.
*
* @returns Returns any channel related data.
*/
getChannelData(): any {
return this.get('channelData', {});
}
/**
* Returns the message id.
* @returns the message id.
*/
abstract getId(): string;
/**
* Sets an event attribute value
*
* @param attr - Event attribute name
* @param value - The value to set for the specified attribute.
*/
set(attr: string, value: any) {
(this._adapter as any).raw[attr] = value;
}
/**
* Returns an event attribute value, default value if it does exist
*
* @param attr - Event attribute name
* @param otherwise - Default value if attribute does not exist
*
* @returns The value of the specified attribute or the default value.
*/
get(attr: string, otherwise: any): any {
return attr in (this._adapter as any).raw
? ((this._adapter as any).raw as any)[attr]
: otherwise || {};
}
/**
* Returns attached NLP parse results
*
* @returns The parsed NLP entities, or null if not available.
*/
getNLP(): Nlp.ParseEntities | null {
return this._nlp;
}
/**
* Attaches the NLP object to the event
*
* @param nlp - NLP parse results
*/
setNLP(nlp: Nlp.ParseEntities) {
this._nlp = nlp;
}
/**
* Returns event sender/profile id (channel's id)
*
* @returns sender/profile id
*/
abstract getSenderForeignId(): string;
/**
* Returns event sender data
*
* @returns event sender data
*/
getSender(): Subscriber {
return this._profile;
}
/**
* Sets event sender data
*
* @param profile - Sender data
*/
setSender(profile: Subscriber) {
this._profile = profile;
}
/**
* Returns event recipient id
*
* @returns event recipient id
*/
abstract getRecipientForeignId(): string;
/**
* Returns the type of event received (message, delivery, read, ...)
*
* @returns The type of event received (message, delivery, read, ...)
*/
abstract getEventType(): StdEventType;
/**
* Identifies the type of the message received
*
* @return The type of message
*/
abstract getMessageType(): IncomingMessageType;
/**
* Return payload whenever user clicks on a button/quick_reply or sends an attachment, false otherwise
*
* @returns The payload content
*/
abstract getPayload(): Payload | string | undefined;
/**
* Returns the message in a standardized format
*
* @returns The received message
*/
abstract getMessage(): any;
/**
* Return the text message received
*
* @returns Received text message
*/
getText(): string {
const message = this.getMessage();
if ('text' in message) {
return message.text;
} else if ('serialized_text' in message) {
return message.serialized_text;
}
return '';
}
/**
* Returns the list of received attachments
*
* @returns Received attachments message
*/
abstract getAttachments(): AttachmentPayload<AttachmentForeignKey>[];
/**
* Returns the list of delivered messages
*
* @returns Array of message ids
*/
abstract getDeliveredMessages(): string[];
/**
* Returns the message's watermark
*
* @returns The message's watermark
*/
abstract getWatermark(): number;
}
type GenericEvent = { senderId: string; messageId: string };
type GenericEventAdapter = {
eventType: StdEventType.unknown;
messageType: IncomingMessageType.unknown;
raw: GenericEvent;
};
export class GenericEventWrapper extends EventWrapper<
GenericEventAdapter,
GenericEvent
> {
/**
* Constructor : channel's event wrapper
*
* @param handler - The channel's handler
* @param event - The message event received
*/
constructor(handler: ChannelHandler, event: GenericEvent) {
super(handler, event);
}
/**
* Called by the parent constructor, it defines :
*
* - The type of event received
*
* - The type of message when the event is a message.
*
* - Sets a typed raw object of the event data
* @param event - The message event received
*/
_init(event: GenericEvent): void {
this._adapter.eventType = StdEventType.unknown;
this._adapter.messageType = IncomingMessageType.unknown;
this._adapter.raw = event;
}
/**
* Returns channel related data
*
* @returns An object representing the channel specific data
*/
getChannelData(): any {
return this.get('channelData', {});
}
/**
* Returns the message id
*
* @returns The message id
*/
getId(): string {
if (this._adapter.raw.messageId) {
return this._adapter.raw.messageId;
}
throw new Error('The message id `mid` is missing');
}
/**
* Returns event sender id
*
* @returns event sender id
*/
getSenderForeignId(): string {
if (this._adapter.raw.senderId) {
return this._adapter.raw.senderId;
}
throw new Error('The sender id is missing');
}
/**
* Returns event recipient id (channel's id)
*
* @returns Returns event recipient id
*/
getRecipientForeignId(): string {
throw new Error('The recipient id is missing');
}
/**
* Returns the type of event received
*
* @returns The type of event received
*/
getEventType(): StdEventType {
return this._adapter.eventType;
}
/**
* Finds out and returns the type of the event received from the channel
*
* @returns The type of message
*/
getMessageType(): IncomingMessageType {
return this._adapter.messageType;
}
/**
* Returns payload whenever user clicks on a button/quick_reply or sends an attachment
*
* @returns The payload content
*/
getPayload(): Payload | string | undefined {
return undefined;
}
/**
* Returns a standard message format that can be stored in DB
*
* @returns Received message in standard format
*/
getMessage(): StdIncomingMessage {
throw new Error('Unknown incoming message type');
}
/**
* @returns A list of received attachments
* @deprecated - This method is deprecated
*/
getAttachments(): AttachmentPayload<AttachmentForeignKey>[] {
return [];
}
/**
* Returns the delivered messages ids
*
* @returns return delivered messages ids
*/
getDeliveredMessages(): string[] {
return [];
}
/**
* Returns the message's watermark (timestamp or equivalent).
*
* @returns The message's watermark
*/
getWatermark() {
return 0;
}
}

View File

@@ -0,0 +1,211 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto';
import {
StdOutgoingEnvelope,
StdOutgoingMessage,
} from '@/chat/schemas/types/message';
import { LoggerService } from '@/logger/logger.service';
import BaseNlpHelper from '@/nlp/lib/BaseNlpHelper';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingCreateDto } from '@/setting/dto/setting.dto';
import { SettingService } from '@/setting/services/setting.service';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
import EventWrapper from './EventWrapper';
import { ChannelService } from '../channel.service';
@Injectable()
export default abstract class ChannelHandler {
protected settings: SettingCreateDto[] = [];
protected NLP: BaseNlpHelper;
constructor(
protected readonly settingService: SettingService,
private readonly channelService: ChannelService,
protected readonly nlpService: NlpService,
protected readonly logger: LoggerService,
) {}
onModuleInit() {
this.channelService.setChannel(this.getChannel(), this);
this.setup();
}
async setup() {
await this.settingService.seedIfNotExist(this.getChannel(), this.settings);
const nlp = this.nlpService.getNLP();
this.setNLP(nlp);
this.init();
}
setNLP(nlp: BaseNlpHelper) {
this.NLP = nlp;
}
getNLP() {
return this.NLP;
}
/**
* Returns the channel specific settings
*/
async getSettings<S>() {
const settings = await this.settingService.getSettings();
return settings[this.getChannel()] as S;
}
/**
* Returns the channel's name
* @returns {String}
*/
abstract getChannel(): string;
/**
* Perform any initialization needed
* @returns
*/
abstract init(): void;
/**
* @param {module:Controller.req} req
* @param {module:Controller.res} res
* Process incoming channel data via POST/GET methods
*/
abstract handle(
req: Request | SocketRequest,
res: Response | SocketResponse,
): any;
/**
* Format a text message that will be sent to the channel
* @param message - A text to be sent to the end user
* @param options - might contain additional settings
* @returns {Object} - A text message in the channel specific format
*/
abstract _textFormat(message: StdOutgoingMessage, options?: any): any;
/**
* @param message - A text + quick replies to be sent to the end user
* @param options - might contain additional settings
* @returns {Object} - A quick replies message in the channel specific format
* Format a text + quick replies message that can be sent to the channel
*/
abstract _quickRepliesFormat(message: StdOutgoingMessage, options?: any): any;
/**
* @param message - A text + buttons to be sent to the end user
* @param options - Might contain additional settings
* @returns {Object} - A buttons message in the format required by the channel
* From raw buttons, construct a channel understable message containing those buttons
*/
abstract _buttonsFormat(
message: StdOutgoingMessage,
options?: any,
...args: any
): any;
/**
* @param message - An attachment + quick replies to be sent to the end user
* @param options - Might contain additional settings
* @returns {Object} - An attachment message in the format required by the channel
* Format an attachment + quick replies message that can be sent to the channel
*/
abstract _attachmentFormat(message: StdOutgoingMessage, options?: any): any;
/**
* @param data - A list of data items to be sent to the end user
* @param options - Might contain additional settings
* @returns {Object[]} - An array of element objects
* Format a collection of items to be sent to the channel in carousel/list format
*/
abstract _formatElements(data: any[], options: any, ...args: any): any[];
/**
* Format a list of elements
* @param message - Contains elements to be sent to the end user
* @param options - Might contain additional settings
* @returns {Object} - A ready to be sent list template message in the format required by the channel
*/
abstract _listFormat(
message: StdOutgoingMessage,
options: any,
...args: any
): any;
/**
* Format a carousel message
* @param message - Contains elements to be sent to the end user
* @param options - Might contain additional settings
* @returns {Object} - A carousel ready to be sent in the format required by the channel
*/
abstract _carouselFormat(
message: StdOutgoingMessage,
options: any,
...args: any
): any;
/**
* Send a channel Message to the end user
* @param event - Incoming event/message being responded to
* @param envelope - The message to be sent {format, message}
* @param options - Might contain additional settings
* @param context - Contextual data
* @returns {Promise} - The channel's response, otherwise an error
*/
abstract sendMessage(
event: EventWrapper<any, any>,
envelope: StdOutgoingEnvelope,
options: any,
context: any,
): Promise<{ mid: string }>;
/**
* Fetch the end user profile data
* @param event - The message event received
* @returns {Promise<Subscriber>} - The channel's response, otherwise an error
*/
abstract getUserData(
event: EventWrapper<any, any>,
): Promise<SubscriberCreateDto>;
/**
* @param _attachment - The attachment that needs to be uploaded to the channel
* @returns {Promise<Attachment>}
* Uploads an attachment to the channel as some require file to be uploaded so
* that they could be used in messaging (dimelo, twitter, ...)
*/
async uploadAttachment(_attachment: Attachment): Promise<Attachment> {
return _attachment;
}
/**
* Custom channel middleware
* @param req
* @param res
* @param next
*/
async middleware(_req: Request, _res: Response, next: NextFunction) {
// Do nothing, override in channel
next();
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
export const modelInstance = {
id: '1',
createdAt: new Date(),
updatedAt: new Date(),
};

View File

@@ -0,0 +1,174 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { WithUrl } from '@/chat/schemas/types/attachment';
import { ButtonType } from '@/chat/schemas/types/button';
import {
FileType,
OutgoingMessageFormat,
StdOutgoingAttachmentMessage,
StdOutgoingButtonsMessage,
StdOutgoingListMessage,
StdOutgoingQuickRepliesMessage,
StdOutgoingTextMessage,
} from '@/chat/schemas/types/message';
import { QuickReplyType } from '@/chat/schemas/types/quick-reply';
export const textMessage: StdOutgoingTextMessage = {
text: 'Hello World',
};
export const quickRepliesMessage: StdOutgoingQuickRepliesMessage = {
text: 'Choose one option',
quickReplies: [
{
content_type: QuickReplyType.text,
title: 'First option',
payload: 'first_option',
},
{
content_type: QuickReplyType.text,
title: 'Second option',
payload: 'second_option',
},
],
};
export const buttonsMessage: StdOutgoingButtonsMessage = {
text: 'Hit one of these buttons :',
buttons: [
{
type: ButtonType.postback,
title: 'First button',
payload: 'first_button',
},
{
type: ButtonType.web_url,
title: 'Second button',
url: 'http://button.com',
messenger_extensions: true,
webview_height_ratio: 'compact',
},
],
};
export const urlButtonsMessage: StdOutgoingButtonsMessage = {
text: 'Hit one of these buttons :',
buttons: [
{
type: ButtonType.web_url,
title: 'First button',
url: 'http://button1.com',
messenger_extensions: true,
webview_height_ratio: 'compact',
},
{
type: ButtonType.web_url,
title: 'Second button',
url: 'http://button2.com',
messenger_extensions: true,
webview_height_ratio: 'compact',
},
],
};
const attachment: Attachment = {
id: '1',
name: 'attachment.jpg',
type: 'image/jpeg',
size: 3539,
location: '39991e51-55c6-4a26-9176-b6ba04f180dc.jpg',
channel: {
['dimelo']: {
id: 'attachment-id-dimelo',
},
},
createdAt: new Date(),
updatedAt: new Date(),
};
const attachmentWithUrl: WithUrl<Attachment> = {
...attachment,
url: 'http://localhost:4000/attachment/download/1/attachment.jpg',
};
export const contentMessage: StdOutgoingListMessage = {
options: {
display: OutgoingMessageFormat.list,
fields: {
title: 'title',
subtitle: 'desc',
image_url: 'thumbnail',
},
buttons: [
{
type: ButtonType.postback,
title: 'More',
payload: '',
},
],
limit: 2,
},
elements: [
{
id: '1',
entity: 'rank',
title: 'First',
// @ts-expect-error Necessary workaround
desc: 'About being first',
thumbnail: {
payload: attachmentWithUrl,
},
getPayload() {
return this.title;
},
createdAt: new Date(),
updatedAt: new Date(),
status: true,
},
{
id: '2',
entity: 'rank',
title: 'Second',
// @ts-expect-error Necessary workaround
desc: 'About being second',
thumbnail: {
payload: attachmentWithUrl,
},
getPayload() {
return this.title;
},
createdAt: new Date(),
updatedAt: new Date(),
status: true,
},
],
pagination: {
total: 3,
skip: 0,
limit: 1,
},
};
export const attachmentMessage: StdOutgoingAttachmentMessage<
WithUrl<Attachment>
> = {
attachment: {
type: FileType.image,
payload: attachmentWithUrl,
},
quickReplies: [
{
content_type: QuickReplyType.text,
title: 'Next >',
payload: 'NEXT',
},
],
};

View File

@@ -0,0 +1,63 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Label } from '@/chat/schemas/label.schema';
import { modelInstance } from './base.mock';
const baseLabel: Label = {
...modelInstance,
title: '',
name: '',
label_id: {
messenger: '',
offline: '',
dimelo: '',
twitter: '',
},
description: '',
builtin: false,
};
export const labelMock: Label = {
...baseLabel,
title: 'Label',
name: 'label',
label_id: {
messenger: 'none',
offline: 'none',
dimelo: 'none',
twitter: 'none',
},
};
export const customerLabelsMock: Label[] = [
{
...baseLabel,
title: 'Client',
name: 'client',
label_id: {
messenger: 'none',
offline: 'none',
dimelo: 'none',
twitter: 'none',
},
},
{
...baseLabel,
title: 'Professional',
name: 'profressional',
label_id: {
messenger: 'none',
offline: 'none',
dimelo: 'none',
twitter: 'none',
},
},
];

View File

@@ -0,0 +1,44 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Subscriber } from '@/chat/schemas/subscriber.schema';
import { modelInstance } from './base.mock';
import { customerLabelsMock } from './label.mock';
export const subscriberInstance: Subscriber = {
foreign_id: 'foreign-id-for-jhon-doe',
first_name: 'John',
last_name: 'Doe',
language: 'fr',
locale: 'fr_FR',
gender: 'male',
timezone: -1,
country: 'TN',
assignedTo: null,
assignedAt: null,
lastvisit: new Date(),
retainedFrom: new Date(),
channel: {
name: 'offline',
},
labels: [],
...modelInstance,
};
export const subscriberWithoutLabels: Subscriber = {
...subscriberInstance,
labels: [],
};
export const subscriberWithLabels: Subscriber = {
...subscriberWithoutLabels,
labels: customerLabelsMock.map(({ id }) => id),
assignedTo: null,
};

View File

@@ -0,0 +1,70 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express'; // Import the Express request and response types
import { LoggerService } from '@/logger/logger.service';
import { Roles } from '@/utils/decorators/roles.decorator';
import { ChannelService } from './channel.service';
@Controller('webhook')
export class WebhookController {
constructor(
private readonly channelService: ChannelService,
private readonly logger: LoggerService,
) {}
/**
* Handles GET requests of a specific channel.
* This endpoint is accessible to public access (messaging platforms).
* It logs the request method and the channel name, then delegates the request
* to the `channelService` for further handling.
*
* @param channel - The name of the channel for which the request is being sent.
* @param req - The HTTP request object.
* @param res - The HTTP response object.
*
* @returns A promise that resolves with the result of the `channelService.handle` method.
*/
@Roles('public')
@Get(':channel')
async handleGet(
@Param('channel') channel: string,
@Req() req: Request,
@Res() res: Response,
): Promise<any> {
this.logger.log('Channel notification : ', req.method, channel);
return await this.channelService.handle(channel, req, res);
}
/**
* Handles POST requests for a specific channel.
* This endpoint is accessible to public access (messaging platforms).
* It logs the request method and the channel name, then delegates the request
* to the `channelService` for further handling.
*
* @param channel - The name of the channel for which the notification is being sent.
* @param req - The HTTP request object.
* @param res - The HTTP response object.
*
* @returns A promise that resolves with the result of the `channelService.handle` method.
*/
@Roles('public')
@Post(':channel')
async handlePost(
@Param('channel') channel: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
this.logger.log('Channel notification : ', req.method, channel);
return await this.channelService.handle(channel, req, res);
}
}

111
api/src/chat/chat.module.ts Normal file
View File

@@ -0,0 +1,111 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { forwardRef, Module } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentModule } from '@/attachment/attachment.module';
import { ChannelModule } from '@/channel/channel.module';
import { CmsModule } from '@/cms/cms.module';
import { NlpModule } from '@/nlp/nlp.module';
import { UserModule } from '@/user/user.module';
import { BlockController } from './controllers/block.controller';
import { CategoryController } from './controllers/category.controller';
import { ContextVarController } from './controllers/context-var.controller';
import { LabelController } from './controllers/label.controller';
import { MessageController } from './controllers/message.controller';
import { SubscriberController } from './controllers/subscriber.controller';
import { TranslationController } from './controllers/translation.controller';
import { BlockRepository } from './repositories/block.repository';
import { CategoryRepository } from './repositories/category.repository';
import { ContextVarRepository } from './repositories/context-var.repository';
import { ConversationRepository } from './repositories/conversation.repository';
import { LabelRepository } from './repositories/label.repository';
import { MessageRepository } from './repositories/message.repository';
import { SubscriberRepository } from './repositories/subscriber.repository';
import { TranslationRepository } from './repositories/translation.repository';
import { BlockModel } from './schemas/block.schema';
import { CategoryModel } from './schemas/category.schema';
import { ContextVarModel } from './schemas/context-var.schema';
import { ConversationModel } from './schemas/conversation.schema';
import { LabelModel } from './schemas/label.schema';
import { MessageModel } from './schemas/message.schema';
import { SubscriberModel } from './schemas/subscriber.schema';
import { TranslationModel } from './schemas/translation.schema';
import { CategorySeeder } from './seeds/category.seed';
import { ContextVarSeeder } from './seeds/context-var.seed';
import { TranslationSeeder } from './seeds/translation.seed';
import { BlockService } from './services/block.service';
import { BotService } from './services/bot.service';
import { CategoryService } from './services/category.service';
import { ChatService } from './services/chat.service';
import { ContextVarService } from './services/context-var.service';
import { ConversationService } from './services/conversation.service';
import { LabelService } from './services/label.service';
import { MessageService } from './services/message.service';
import { SubscriberService } from './services/subscriber.service';
import { TranslationService } from './services/translation.service';
@Module({
imports: [
MongooseModule.forFeature([
CategoryModel,
ContextVarModel,
LabelModel,
BlockModel,
MessageModel,
SubscriberModel,
TranslationModel,
ConversationModel,
SubscriberModel,
]),
forwardRef(() => ChannelModule),
CmsModule,
AttachmentModule,
NlpModule,
EventEmitter2,
UserModule,
],
controllers: [
CategoryController,
ContextVarController,
LabelController,
BlockController,
MessageController,
SubscriberController,
TranslationController,
],
providers: [
CategoryRepository,
ContextVarRepository,
LabelRepository,
BlockRepository,
MessageRepository,
SubscriberRepository,
TranslationRepository,
ConversationRepository,
CategoryService,
ContextVarService,
LabelService,
BlockService,
MessageService,
SubscriberService,
TranslationService,
CategorySeeder,
ContextVarSeeder,
ConversationService,
ChatService,
BotService,
TranslationSeeder,
],
exports: [SubscriberService, MessageService, LabelService, BlockService],
})
export class ChatModule {}

View File

@@ -0,0 +1,325 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { ContentModel } from '@/cms/schemas/content.schema';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import { PermissionRepository } from '@/user/repositories/permission.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel } from '@/user/schemas/user.schema';
import { PermissionService } from '@/user/services/permission.service';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
blockFixtures,
installBlockFixtures,
} from '@/utils/test/fixtures/block';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { CategoryModel, Category } from './../schemas/category.schema';
import { BlockController } from './block.controller';
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
import { BlockRepository } from '../repositories/block.repository';
import { CategoryRepository } from '../repositories/category.repository';
import { LabelRepository } from '../repositories/label.repository';
import { BlockModel, Block } from '../schemas/block.schema';
import { LabelModel } from '../schemas/label.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
import { LabelService } from '../services/label.service';
describe('BlockController', () => {
let blockController: BlockController;
let blockService: BlockService;
let categoryService: CategoryService;
let category: Category;
let block: Block;
let blockToDelete: Block;
let hasNextBlocks: Block;
let hasPreviousBlocks: Block;
const FIELDS_TO_POPULATE = [
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
];
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [BlockController],
imports: [
rootMongooseTestModule(installBlockFixtures),
MongooseModule.forFeature([
BlockModel,
LabelModel,
CategoryModel,
ContentModel,
AttachmentModel,
UserModel,
RoleModel,
PermissionModel,
]),
],
providers: [
BlockRepository,
LabelRepository,
CategoryRepository,
ContentRepository,
AttachmentRepository,
UserRepository,
RoleRepository,
PermissionRepository,
BlockService,
LabelService,
CategoryService,
ContentService,
AttachmentService,
UserService,
RoleService,
PermissionService,
PluginService,
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
blockController = module.get<BlockController>(BlockController);
blockService = module.get<BlockService>(BlockService);
categoryService = module.get<CategoryService>(CategoryService);
category = await categoryService.findOne({ label: 'default' });
block = await blockService.findOne({ name: 'first' });
blockToDelete = await blockService.findOne({ name: 'buttons' });
hasNextBlocks = await blockService.findOne({
name: 'hasNextBlocks',
});
hasPreviousBlocks = await blockService.findOne({
name: 'hasPreviousBlocks',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('find', () => {
it('should find all blocks', async () => {
jest.spyOn(blockService, 'find');
const result = await blockController.find([], {});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category: category.id,
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks.id] : [],
}));
expect(blockService.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory, [
...IGNORED_TEST_FIELDS,
'attachedToBlock',
]);
});
it('should find all blocks, and foreach block populate the corresponding category and previousBlocks', async () => {
jest.spyOn(blockService, 'findAndPopulate');
const category = await categoryService.findOne({ label: 'default' });
const result = await blockController.find(FIELDS_TO_POPULATE, {});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
}));
expect(blockService.findAndPopulate).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory);
});
});
describe('findOne', () => {
it('should find one block by id', async () => {
jest.spyOn(blockService, 'findOne');
const result = await blockController.findOne(hasNextBlocks.id, []);
expect(blockService.findOne).toHaveBeenCalledWith(hasNextBlocks.id);
expect(result).toEqualPayload(
{
...blockFixtures.find(({ name }) => name === hasNextBlocks.name),
category: category.id,
nextBlocks: [hasPreviousBlocks.id],
},
[...IGNORED_TEST_FIELDS, 'attachedToBlock'],
);
});
it('should find one block by id, and populate its category and previousBlocks', async () => {
jest.spyOn(blockService, 'findOneAndPopulate');
const result = await blockController.findOne(
hasPreviousBlocks.id,
FIELDS_TO_POPULATE,
);
expect(blockService.findOneAndPopulate).toHaveBeenCalledWith(
hasPreviousBlocks.id,
);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === 'hasPreviousBlocks'),
category,
previousBlocks: [hasNextBlocks],
});
});
it('should find one block by id, and populate its category and an empty previousBlocks', async () => {
jest.spyOn(blockService, 'findOneAndPopulate');
block = await blockService.findOne({ name: 'attachment' });
const result = await blockController.findOne(
block.id,
FIELDS_TO_POPULATE,
);
expect(blockService.findOneAndPopulate).toHaveBeenCalledWith(block.id);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === 'attachment'),
category,
previousBlocks: [],
});
});
});
describe('create', () => {
it('should return created block', async () => {
jest.spyOn(blockService, 'create');
const mockedBlockCreateDto: BlockCreateDto = {
name: 'block with nextBlocks',
nextBlocks: [hasNextBlocks.id],
patterns: ['Hi'],
trigger_labels: [],
assign_labels: [],
trigger_channels: [],
category: category.id,
options: {
typing: 0,
fallback: {
active: false,
max_attempts: 1,
message: [],
},
},
message: ['Hi back !'],
starts_conversation: false,
capture_vars: [],
position: {
x: 0,
y: 0,
},
};
const result = await blockController.create(mockedBlockCreateDto);
expect(blockService.create).toHaveBeenCalledWith(mockedBlockCreateDto);
expect(result).toEqualPayload(
{
...mockedBlockCreateDto,
},
[...IGNORED_TEST_FIELDS, 'nextBlocks', 'builtin'],
);
});
});
describe('deleteOne', () => {
it('should delete block', async () => {
jest.spyOn(blockService, 'deleteOne');
const result = await blockController.deleteOne(blockToDelete.id);
expect(blockService.deleteOne).toHaveBeenCalledWith(blockToDelete.id);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
it('should throw NotFoundException when attempting to delete a block by id', async () => {
await expect(blockController.deleteOne(blockToDelete.id)).rejects.toThrow(
new NotFoundException(`Block with ID ${blockToDelete.id} not found`),
);
});
});
describe('updateOne', () => {
it('should return updated block', async () => {
jest.spyOn(blockService, 'updateOne');
const updateBlock: BlockUpdateDto = {
name: 'modified block name',
};
const result = await blockController.updateOne(block.id, updateBlock);
expect(blockService.updateOne).toHaveBeenCalledWith(
block.id,
updateBlock,
);
expect(result).toEqualPayload(
{
...blockFixtures.find(({ name }) => name === block.name),
category: category.id,
...updateBlock,
},
[...IGNORED_TEST_FIELDS, 'attachedToBlock'],
);
});
it('should throw NotFoundException when attempting to update a block by id', async () => {
const updateBlock: BlockUpdateDto = {
name: 'attempt to modify block name',
};
await expect(
blockController.updateOne(blockToDelete.id, updateBlock),
).rejects.toThrow(
new NotFoundException(`Block with ID ${blockToDelete.id} not found`),
);
});
});
});

View File

@@ -0,0 +1,300 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseBlockPlugin } from '@/plugins/base-block-plugin';
import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { UserService } from '@/user/services/user.service';
import { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
import { Block, BlockFull, BlockStub } from '../schemas/block.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
import { LabelService } from '../services/label.service';
@UseInterceptors(CsrfInterceptor)
@Controller('Block')
export class BlockController extends BaseController<Block, BlockStub> {
constructor(
private readonly blockService: BlockService,
private readonly logger: LoggerService,
private readonly categoryService: CategoryService,
private readonly labelService: LabelService,
private readonly userService: UserService,
private pluginsService: PluginService<BaseBlockPlugin>,
) {
super(blockService);
}
/**
* Finds blocks based on the provided query parameters.
* @param populate - An array of fields to populate in the returned blocks.
* @param filters - Query filters to apply to the block search.
* @returns A Promise that resolves to an array of found blocks.
*/
@Get()
async find(
@Query(PopulatePipe)
populate: string[],
@Query(new SearchFilterPipe<Block>({ allowedFields: ['category'] }))
filters: TFilterQuery<Block>,
): Promise<Block[] | BlockFull[]> {
return this.canPopulate(populate, [
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
])
? await this.blockService.findAndPopulate(filters)
: await this.blockService.find(filters);
}
/**
* Retrieves a custom block settings for a specific plugin.
*
* @param pluginId - The name of the plugin for which settings are to be retrieved.
*
* @returns An array containing the settings of the specified plugin.
*/
@Get('customBlocks/settings')
findSettings(@Query('plugin') pluginId: string) {
try {
if (!pluginId) {
throw new BadRequestException(
'Plugin id must be supplied as a query param',
);
}
const plugin = this.pluginsService.getPlugin(PluginType.block, pluginId);
if (!plugin) {
throw new NotFoundException('Plugin Not Found');
}
return plugin.settings;
} catch (e) {
this.logger.error('Unable to fetch plugin settings', e);
throw e;
}
}
/**
* Retrieves all custom blocks (plugins) along with their associated block template.
*
* @returns An array containing available custom blocks.
*/
@Get('customBlocks')
findAll() {
try {
const plugins = this.pluginsService
.getAllByType(PluginType.block)
.map((p) => ({
title: p.title,
name: p.id,
template: {
...p.template,
message: {
plugin: p.id,
args: p.settings.reduce(
(acc, setting) => {
acc[setting.id] = setting.value;
return acc;
},
{} as { [key: string]: any },
),
},
},
effects: typeof p.effects === 'object' ? Object.keys(p.effects) : [],
}));
return plugins;
} catch (e) {
this.logger.error(e);
throw e;
}
}
// @TODO : remove once old frontend is abandoned
/**
* Retrieves the effects of all plugins that have effects defined.
*
* @returns An array containing objects representing the effects of plugins.
*/
@Get('effects')
findEffects(): {
name: string;
title: any;
}[] {
try {
const plugins = this.pluginsService.getAllByType(PluginType.block);
const effects = Object.keys(plugins)
.filter(
(plugin) =>
typeof plugins[plugin].effects === 'object' &&
Object.keys(plugins[plugin].effects).length > 0,
)
.map((plugin) => ({
name: plugin,
title: plugins[plugin].title,
}));
return effects;
} catch (e) {
this.logger.error(e);
throw e;
}
}
/**
* Retrieves a single block by its ID.
*
* @param id - The ID of the block to retrieve.
* @param populate - An array of fields to populate in the retrieved block.
* @returns A Promise that resolves to the retrieved block.
*/
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
): Promise<Block | BlockFull> {
const doc = this.canPopulate(populate, [
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
])
? await this.blockService.findOneAndPopulate(id)
: await this.blockService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Block by id ${id}`);
throw new NotFoundException(`Block with ID ${id} not found`);
}
return doc;
}
/**
* Creates a new block.
*
* @param block - The data of the block to be created.
* @returns A Promise that resolves to the created block.
*/
@CsrfCheck(true)
@Post()
async create(@Body() block: BlockCreateDto): Promise<Block> {
this.validate({
dto: block,
allowedIds: {
category: (await this.categoryService.findOne(block.category))?.id,
attachedBlock: (await this.blockService.findOne(block.attachedBlock))
?.id,
nextBlocks: (
await this.blockService.find({
_id: {
$in: block.nextBlocks,
},
})
).map(({ id }) => id),
assign_labels: (
await this.labelService.find({
_id: {
$in: block.assign_labels,
},
})
).map(({ id }) => id),
trigger_labels: (
await this.labelService.find({
_id: {
$in: block.trigger_labels,
},
})
).map(({ id }) => id),
},
});
// TODO: the validate function doesn't support nested objects, we need to refactor it to support nested objects
if (block.options?.assignTo) {
const user = await this.userService.findOne(block.options.assignTo);
if (!user) {
throw new BadRequestException(
`options.assignTo with ID ${block.options.assignTo} not found`,
);
}
}
return await this.blockService.create(block);
}
/**
* Updates a specific block by ID.
*
* @param id - The ID of the block to update.
* @param blockUpdate - The data to update the block with.
* @returns A Promise that resolves to the updated block if successful.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() blockUpdate: BlockUpdateDto,
): Promise<Block> {
const result = await this.blockService.updateOne(id, blockUpdate);
if (!result) {
this.logger.warn(`Unable to update Block by id ${id}`);
throw new NotFoundException(`Block with ID ${id} not found`);
}
return result;
}
/**
* Deletes a specific block by ID.
*
* @param id - The ID of the block to delete.
* @returns A Promise that resolves to the deletion result.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.blockService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Block by id ${id}`);
throw new NotFoundException(`Block with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,210 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { ContentModel } from '@/cms/schemas/content.schema';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import {
categoryFixtures,
installCategoryFixtures,
} from '@/utils/test/fixtures/category';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { Category, CategoryModel } from './../schemas/category.schema';
import { CategoryController } from './category.controller';
import { CategoryCreateDto, CategoryUpdateDto } from '../dto/category.dto';
import { BlockRepository } from '../repositories/block.repository';
import { CategoryRepository } from '../repositories/category.repository';
import { BlockModel } from '../schemas/block.schema';
import { LabelModel } from '../schemas/label.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
describe('CategoryController', () => {
let categoryController: CategoryController;
let categoryService: CategoryService;
let category: Category;
let categoryToDelete: Category;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [CategoryController],
imports: [
rootMongooseTestModule(installCategoryFixtures),
MongooseModule.forFeature([
BlockModel,
LabelModel,
CategoryModel,
ContentModel,
AttachmentModel,
]),
],
providers: [
BlockRepository,
CategoryRepository,
ContentRepository,
AttachmentRepository,
BlockService,
CategoryService,
ContentService,
AttachmentService,
{
provide: PluginService,
useValue: {},
},
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
{
provide: BlockService,
useValue: {
findOne: jest.fn(),
},
},
EventEmitter2,
],
}).compile();
categoryService = module.get<CategoryService>(CategoryService);
categoryController = module.get<CategoryController>(CategoryController);
category = await categoryService.findOne({ label: 'test category 1' });
categoryToDelete = await categoryService.findOne({
label: 'test category 2',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findPage', () => {
it('should return an array of categories', async () => {
const pageQuery = getPageQuery<Category>();
const result = await categoryController.findPage(pageQuery, {});
expect(result).toEqualPayload(categoryFixtures.sort(sortRowsBy));
});
});
describe('count', () => {
it('should count categories', async () => {
jest.spyOn(categoryService, 'count');
const result = await categoryController.filterCount();
expect(categoryService.count).toHaveBeenCalled();
expect(result).toEqual({ count: categoryFixtures.length });
});
});
describe('findOne', () => {
it('should return the existing category', async () => {
jest.spyOn(categoryService, 'findOne');
const category = await categoryService.findOne({
label: 'test category 1',
});
const result = await categoryController.findOne(category.id);
expect(categoryService.findOne).toHaveBeenCalledWith(category.id);
expect(result).toEqualPayload({
...categoryFixtures.find(({ label }) => label === 'test category 1'),
});
});
});
describe('create', () => {
it('should return created category', async () => {
jest.spyOn(categoryService, 'create');
const categoryCreateDto: CategoryCreateDto = {
label: 'categoryLabel2',
builtin: true,
zoom: 100,
offset: [0, 0],
};
const result = await categoryController.create(categoryCreateDto);
expect(categoryService.create).toHaveBeenCalledWith(categoryCreateDto);
expect(result).toEqualPayload(categoryCreateDto);
});
});
describe('deleteOne', () => {
it('should delete a category by id', async () => {
jest.spyOn(categoryService, 'deleteOne');
const result = await categoryController.deleteOne(categoryToDelete.id);
expect(categoryService.deleteOne).toHaveBeenCalledWith(
categoryToDelete.id,
);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
it('should throw a NotFoundException when attempting to delete a category by id', async () => {
jest.spyOn(categoryService, 'deleteOne');
const result = categoryController.deleteOne(categoryToDelete.id);
expect(categoryService.deleteOne).toHaveBeenCalledWith(
categoryToDelete.id,
);
await expect(result).rejects.toThrow(
new NotFoundException(
`Category with ID ${categoryToDelete.id} not found`,
),
);
});
});
describe('updateOne', () => {
const categoryUpdateDto: CategoryUpdateDto = {
builtin: false,
};
it('should return updated category', async () => {
jest.spyOn(categoryService, 'updateOne');
const result = await categoryController.updateOne(
category.id,
categoryUpdateDto,
);
expect(categoryService.updateOne).toHaveBeenCalledWith(
category.id,
categoryUpdateDto,
);
expect(result).toEqualPayload({
...categoryFixtures.find(({ label }) => label === 'test category 1'),
...categoryUpdateDto,
});
});
});
});

View File

@@ -0,0 +1,143 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { CategoryCreateDto, CategoryUpdateDto } from '../dto/category.dto';
import { Category } from '../schemas/category.schema';
import { BlockService } from '../services/block.service';
import { CategoryService } from '../services/category.service';
@UseInterceptors(CsrfInterceptor)
@Controller('category')
export class CategoryController extends BaseController<Category> {
constructor(
private readonly categoryService: CategoryService,
private readonly blockService: BlockService,
private readonly logger: LoggerService,
) {
super(categoryService);
}
/**
* Retrieves a paginated list of categories based on provided filters and pagination settings.
* @param pageQuery - The pagination settings.
* @param filters - The filters to apply to the category search.
* @returns A Promise that resolves to a paginated list of categories.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Category>,
@Query(new SearchFilterPipe<Category>({ allowedFields: ['label'] }))
filters: TFilterQuery<Category>,
) {
return await this.categoryService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of categories.
* @returns A promise that resolves to an object representing the filtered number of categories.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Category>({
allowedFields: ['label'],
}),
)
filters?: TFilterQuery<Category>,
) {
return await this.count(filters);
}
/**
* Finds a category by its ID.
* @param id - The ID of the category to find.
* @returns A Promise that resolves to the found category.
*/
@Get(':id')
async findOne(@Param('id') id: string): Promise<Category> {
const doc = await this.categoryService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Category by id ${id}`);
throw new NotFoundException(`Category with ID ${id} not found`);
}
return doc;
}
/**
* Creates a new category.
* @param category - The data of the category to be created.
* @returns A Promise that resolves to the created category.
*/
@CsrfCheck(true)
@Post()
async create(@Body() category: CategoryCreateDto): Promise<Category> {
return await this.categoryService.create(category);
}
/**
* Updates an existing category.
* @param id - The ID of the category to be updated.
* @param categoryUpdate - The updated data for the category.
* @returns A Promise that resolves to the updated category.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() categoryUpdate: CategoryUpdateDto,
): Promise<Category> {
const result = await this.categoryService.updateOne(id, categoryUpdate);
if (!result) {
this.logger.warn(`Unable to update Category by id ${id}`);
throw new NotFoundException(`Category with ID ${id} not found`);
}
return result;
}
/**
* Deletes a category by its ID.
* @param id - The ID of the category to be deleted.
* @returns A Promise that resolves to the deletion result.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.categoryService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Category by id ${id}`);
throw new NotFoundException(`Category with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { NotFoundException } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import {
contextVarFixtures,
installContextVarFixtures,
} from '@/utils/test/fixtures/contextvar';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { ContextVarController } from './context-var.controller';
import {
ContextVarCreateDto,
ContextVarUpdateDto,
} from '../dto/context-var.dto';
import { ContextVarRepository } from '../repositories/context-var.repository';
import { ContextVarModel, ContextVar } from '../schemas/context-var.schema';
import { ContextVarService } from '../services/context-var.service';
describe('ContextVarController', () => {
let contextVarController: ContextVarController;
let contextVarService: ContextVarService;
let contextVar: ContextVar;
let contextVarToDelete: ContextVar;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [ContextVarController],
imports: [
rootMongooseTestModule(installContextVarFixtures),
MongooseModule.forFeature([ContextVarModel]),
],
providers: [LoggerService, ContextVarService, ContextVarRepository],
}).compile();
contextVarController =
module.get<ContextVarController>(ContextVarController);
contextVarService = module.get<ContextVarService>(ContextVarService);
contextVar = await contextVarService.findOne({
label: 'test context var 1',
});
contextVarToDelete = await contextVarService.findOne({
label: 'test context var 2',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count the contextVars', async () => {
jest.spyOn(contextVarService, 'count');
const result = await contextVarController.filterCount();
expect(contextVarService.count).toHaveBeenCalled();
expect(result).toEqual({ count: contextVarFixtures.length });
});
});
describe('findPage', () => {
it('should return an array of contextVars', async () => {
const pageQuery = getPageQuery<ContextVar>();
jest.spyOn(contextVarService, 'findPage');
const result = await contextVarController.findPage(pageQuery, {});
expect(contextVarService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(contextVarFixtures.sort(sortRowsBy));
});
});
describe('findOne', () => {
it('should return the existing contextVar', async () => {
jest.spyOn(contextVarService, 'findOne');
const result = await contextVarController.findOne(contextVar.id);
expect(contextVarService.findOne).toHaveBeenCalledWith(contextVar.id);
expect(result).toEqualPayload(
contextVarFixtures.find(({ label }) => label === contextVar.label),
);
});
});
describe('create', () => {
it('should return created contextVar', async () => {
jest.spyOn(contextVarService, 'create');
const contextVarCreateDto: ContextVarCreateDto = {
label: 'contextVarLabel2',
name: 'test_add',
};
const result = await contextVarController.create(contextVarCreateDto);
expect(contextVarService.create).toHaveBeenCalledWith(
contextVarCreateDto,
);
expect(result).toEqualPayload(contextVarCreateDto);
});
});
describe('deleteOne', () => {
it('should delete a contextVar by id', async () => {
jest.spyOn(contextVarService, 'deleteOne');
const result = await contextVarController.deleteOne(
contextVarToDelete.id,
);
expect(contextVarService.deleteOne).toHaveBeenCalledWith(
contextVarToDelete.id,
);
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
});
it('should throw a NotFoundException when attempting to delete a contextVar by id', async () => {
await expect(
contextVarController.deleteOne(contextVarToDelete.id),
).rejects.toThrow(
new NotFoundException(
`ContextVar with ID ${contextVarToDelete.id} not found`,
),
);
});
});
describe('updateOne', () => {
const contextVarUpdatedDto: ContextVarUpdateDto = {
name: 'updated_context_var_name',
};
it('should return updated contextVar', async () => {
jest.spyOn(contextVarService, 'updateOne');
const result = await contextVarController.updateOne(
contextVar.id,
contextVarUpdatedDto,
);
expect(contextVarService.updateOne).toHaveBeenCalledWith(
contextVar.id,
contextVarUpdatedDto,
);
expect(result).toEqualPayload({
...contextVarFixtures.find(({ label }) => label === contextVar.label),
...contextVarUpdatedDto,
});
});
it('should throw a NotFoundException when attempting to update an non existing contextVar by id', async () => {
await expect(
contextVarController.updateOne(
contextVarToDelete.id,
contextVarUpdatedDto,
),
).rejects.toThrow(
new NotFoundException(
`ContextVar with ID ${contextVarToDelete.id} not found`,
),
);
});
});
});

View File

@@ -0,0 +1,148 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import {
ContextVarCreateDto,
ContextVarUpdateDto,
} from '../dto/context-var.dto';
import { ContextVar } from '../schemas/context-var.schema';
import { ContextVarService } from '../services/context-var.service';
@UseInterceptors(CsrfInterceptor)
@Controller('contextvar')
export class ContextVarController extends BaseController<ContextVar> {
constructor(
private readonly contextVarService: ContextVarService,
private readonly logger: LoggerService,
) {
super(contextVarService);
}
/**
* Finds a page of contextVars based on specified filters and pagination parameters.
* @param pageQuery - The pagination parameters.
* @param filters - The filters to apply.
* @returns A Promise that resolves to an array of contextVars.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<ContextVar>,
@Query(new SearchFilterPipe<ContextVar>({ allowedFields: ['label'] }))
filters: TFilterQuery<ContextVar>,
): Promise<ContextVar[]> {
return await this.contextVarService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of contextVars.
* @returns A promise that resolves to an object representing the filtered number of contextVars.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<ContextVar>({
allowedFields: ['label'],
}),
)
filters?: TFilterQuery<ContextVar>,
) {
return await this.count(filters);
}
/**
* Retrieves a contextVar by its ID.
* @param id - The ID of the contextVar to retrieve.
* @returns A Promise that resolves to the retrieved contextVar.
*/
@Get(':id')
async findOne(@Param('id') id: string): Promise<ContextVar> {
const doc = await this.contextVarService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find ContextVar by id ${id}`);
throw new NotFoundException(`ContextVar with ID ${id} not found`);
}
return doc;
}
/**
* Creates a new contextVar.
* @param contextVar - The data of the contextVar to create.
* @returns A Promise that resolves to the created contextVar.
*/
@CsrfCheck(true)
@Post()
async create(@Body() contextVar: ContextVarCreateDto): Promise<ContextVar> {
return await this.contextVarService.create(contextVar);
}
/**
* Updates an existing contextVar.
* @param id - The ID of the contextVar to update.
* @param contextVarUpdate - The updated data for the contextVar.
* @returns A Promise that resolves to the updated contextVar.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() contextVarUpdate: ContextVarUpdateDto,
): Promise<ContextVar> {
const result = await this.contextVarService.updateOne(id, contextVarUpdate);
if (!result) {
this.logger.warn(`Unable to update ContextVar by id ${id}`);
throw new NotFoundException(`ContextVar with ID ${id} not found`);
}
return result;
}
/**
* Deletes a contextVar.
* @param id - The ID of the contextVar to delete.
* @returns A Promise that resolves to a DeleteResult.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.contextVarService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete ContextVar by id ${id}`);
throw new NotFoundException(`ContextVar with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,237 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import { labelFixtures } from '@/utils/test/fixtures/label';
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LabelController } from './label.controller';
import { LabelCreateDto, LabelUpdateDto } from '../dto/label.dto';
import { LabelRepository } from '../repositories/label.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Label, LabelModel } from '../schemas/label.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { LabelService } from '../services/label.service';
import { SubscriberService } from '../services/subscriber.service';
describe('LabelController', () => {
let labelController: LabelController;
let labelService: LabelService;
let label: Label;
let labelToDelete: Label;
let subscriberService: SubscriberService;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [LabelController],
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([
LabelModel,
UserModel,
RoleModel,
PermissionModel,
SubscriberModel,
AttachmentModel,
]),
],
providers: [
LoggerService,
LabelController,
LabelService,
LabelRepository,
UserService,
UserRepository,
RoleService,
RoleRepository,
SubscriberService,
SubscriberRepository,
EventEmitter2,
AttachmentService,
AttachmentRepository,
],
}).compile();
labelService = module.get<LabelService>(LabelService);
subscriberService = module.get<SubscriberService>(SubscriberService);
labelController = module.get<LabelController>(LabelController);
label = await labelService.findOne({ name: 'TEST_TITLE_1' });
labelToDelete = await labelService.findOne({
name: 'TEST_TITLE_2',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count labels', async () => {
jest.spyOn(labelService, 'count');
const result = await labelController.filterCount();
expect(labelService.count).toHaveBeenCalled();
expect(result).toEqual({ count: labelFixtures.length });
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Label>();
it('should find labels', async () => {
jest.spyOn(labelService, 'findPage');
const result = await labelController.findPage(pageQuery, [], {});
const labelsWithBuiltin = labelFixtures.map((labelFixture) => ({
...labelFixture,
}));
expect(labelService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(labelsWithBuiltin.sort(sortRowsBy), [
...IGNORED_TEST_FIELDS,
'nextBlocks',
]);
});
it('should find labels, and foreach label populate its corresponding users', async () => {
jest.spyOn(labelService, 'findPageAndPopulate');
const result = await labelController.findPage(pageQuery, ['users'], {});
const allLabels = await labelService.findAll();
const allSubscribers = await subscriberService.findAll();
const labelsWithUsers = allLabels.map((label) => ({
...label,
users: allSubscribers,
}));
expect(labelService.findPageAndPopulate).toHaveBeenCalledWith(
{},
pageQuery,
);
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
});
});
describe('findOne', () => {
it('should find one label by id', async () => {
jest.spyOn(labelService, 'findOne');
const result = await labelController.findOne(label.id, []);
expect(labelService.findOne).toHaveBeenCalledWith(label.id);
expect(result).toEqualPayload(
{
...labelFixtures.find(({ name }) => name === label.name),
},
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
);
});
it('should find one label by id, and populate its corresponding users', async () => {
jest.spyOn(labelService, 'findOneAndPopulate');
const result = await labelController.findOne(label.id, ['users']);
const users = await subscriberService.findAll();
expect(labelService.findOneAndPopulate).toHaveBeenCalledWith(label.id);
expect(result).toEqualPayload(
{
...labelFixtures.find(({ name }) => name === label.name),
users,
},
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
);
});
});
describe('create', () => {
it('should create a label', async () => {
jest.spyOn(labelService, 'create');
const labelCreate: LabelCreateDto = {
title: 'Label2',
name: 'LABEL_2',
label_id: {
messenger: 'messenger',
offline: 'offline',
twitter: 'twitter',
dimelo: 'dimelo',
},
description: 'LabelDescription2',
};
const result = await labelController.create(labelCreate);
expect(labelService.create).toHaveBeenCalledWith(labelCreate);
expect(result).toEqualPayload({ ...labelCreate, builtin: false });
});
});
describe('deleteOne', () => {
it('should delete one label by id', async () => {
jest.spyOn(labelService, 'deleteOne');
const result = await labelController.deleteOne(labelToDelete.id);
expect(labelService.deleteOne).toHaveBeenCalledWith(labelToDelete.id);
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
});
it('should throw a NotFoundException when attempting to delete a non existing label by id', async () => {
await expect(labelController.deleteOne(labelToDelete.id)).rejects.toThrow(
new NotFoundException(`Label with ID ${labelToDelete.id} not found`),
);
});
});
describe('updateOne', () => {
const labelUpdateDto: LabelUpdateDto = {
description: 'test description 1',
};
it('should update a label by id', async () => {
jest.spyOn(labelService, 'updateOne');
const result = await labelController.updateOne(label.id, labelUpdateDto);
expect(labelService.updateOne).toHaveBeenCalledWith(
label.id,
labelUpdateDto,
);
expect(result).toEqualPayload(
{
...labelFixtures.find(({ name }) => name === label.name),
...labelUpdateDto,
},
[...IGNORED_TEST_FIELDS, 'nextBlocks'],
);
});
it('should throw a NotFoundException when attempting to update a non existing label by id', async () => {
await expect(
labelController.updateOne(labelToDelete.id, labelUpdateDto),
).rejects.toThrow(
new NotFoundException(`Label with ID ${labelToDelete.id} not found`),
);
});
});
});

View File

@@ -0,0 +1,124 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Post,
Patch,
Query,
UseInterceptors,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseController } from '@/utils/generics/base-controller';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { LabelCreateDto, LabelUpdateDto } from '../dto/label.dto';
import { Label, LabelStub } from '../schemas/label.schema';
import { LabelService } from '../services/label.service';
@UseInterceptors(CsrfInterceptor)
@Controller('label')
export class LabelController extends BaseController<Label, LabelStub> {
constructor(
private readonly labelService: LabelService,
private readonly logger: LoggerService,
) {
super(labelService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Label>,
@Query(PopulatePipe)
populate: string[],
@Query(new SearchFilterPipe<Label>({ allowedFields: ['name', 'title'] }))
filters: TFilterQuery<Label>,
) {
return this.canPopulate(populate, ['users'])
? await this.labelService.findPageAndPopulate(filters, pageQuery)
: await this.labelService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of labels.
* @returns A promise that resolves to an object representing the filtered number of labels.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Label>({
allowedFields: ['name', 'title'],
}),
)
filters?: TFilterQuery<Label>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
) {
const doc = this.canPopulate(populate, ['users'])
? await this.labelService.findOneAndPopulate(id)
: await this.labelService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Label by id ${id}`);
throw new NotFoundException(`Label with ID ${id} not found`);
}
return doc;
}
@CsrfCheck(true)
@Post()
async create(@Body() label: LabelCreateDto) {
return await this.labelService.create(label);
}
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() labelUpdate: LabelUpdateDto,
) {
const result = await this.labelService.updateOne(id, labelUpdate);
if (!result) {
this.logger.warn(`Unable to update Label by id ${id}`);
throw new NotFoundException(`Label with ID ${id} not found`);
}
return result;
}
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string) {
const result = await this.labelService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Label by id ${id}`);
throw new NotFoundException(`Label with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,223 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ChannelService } from '@/channel/channel.service';
import { MenuRepository } from '@/cms/repositories/menu.repository';
import { MenuModel } from '@/cms/schemas/menu.schema';
import { MenuService } from '@/cms/services/menu.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingService } from '@/setting/services/setting.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import {
installMessageFixtures,
messageFixtures,
} from '@/utils/test/fixtures/message';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MessageController } from './message.controller';
import { MessageRepository } from '../repositories/message.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Message, MessageModel } from '../schemas/message.schema';
import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
import { MessageService } from '../services/message.service';
import { SubscriberService } from '../services/subscriber.service';
describe('MessageController', () => {
let messageController: MessageController;
let messageService: MessageService;
let subscriberService: SubscriberService;
let userService: UserService;
let sender: Subscriber;
let recipient: Subscriber;
let user: User;
let message: Message;
let allMessages: Message[];
let allUsers: User[];
let allSubscribers: Subscriber[];
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [MessageController],
imports: [
rootMongooseTestModule(installMessageFixtures),
MongooseModule.forFeature([
SubscriberModel,
MessageModel,
UserModel,
RoleModel,
PermissionModel,
AttachmentModel,
MenuModel,
]),
],
providers: [
MessageController,
MessageRepository,
MessageService,
SubscriberService,
UserService,
UserRepository,
RoleService,
RoleRepository,
SubscriberRepository,
ChannelService,
AttachmentService,
AttachmentRepository,
MenuService,
MenuRepository,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: NlpService,
useValue: {
getNLP: jest.fn(() => undefined),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
}).compile();
messageService = module.get<MessageService>(MessageService);
userService = module.get<UserService>(UserService);
subscriberService = module.get<SubscriberService>(SubscriberService);
messageController = module.get<MessageController>(MessageController);
message = await messageService.findOne({ mid: 'mid-1' });
sender = await subscriberService.findOne(message.sender);
recipient = await subscriberService.findOne(message.recipient);
user = await userService.findOne(message.sentBy);
allSubscribers = await subscriberService.findAll();
allUsers = await userService.findAll();
allMessages = await messageService.findAll();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count messages', async () => {
jest.spyOn(messageService, 'count');
const result = await messageController.filterCount();
expect(messageService.count).toHaveBeenCalled();
expect(result).toEqual({ count: messageFixtures.length });
});
});
describe('findOne', () => {
it('should find message by id, and populate its corresponding sender and recipient', async () => {
jest.spyOn(messageService, 'findOneAndPopulate');
const result = await messageController.findOne(message.id, [
'sender',
'recipient',
]);
expect(messageService.findOneAndPopulate).toHaveBeenCalledWith(
message.id,
);
expect(result).toEqualPayload({
...messageFixtures.find(({ mid }) => mid === message.mid),
sender,
recipient,
sentBy: user.id,
});
});
it('should find message by id', async () => {
jest.spyOn(messageService, 'findOne');
const result = await messageController.findOne(message.id, []);
expect(messageService.findOne).toHaveBeenCalledWith(message.id);
expect(result).toEqualPayload({
...messageFixtures.find(({ mid }) => mid === message.mid),
sender: sender.id,
recipient: recipient.id,
sentBy: user.id,
});
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Message>();
it('should find messages', async () => {
jest.spyOn(messageService, 'findPage');
const result = await messageController.findPage(pageQuery, [], {});
const messagesWithSenderAndRecipient = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']).id,
recipient: allSubscribers.find(({ id }) => id === message['recipient'])
.id,
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
expect(messageService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(messagesWithSenderAndRecipient);
});
it('should find messages, and foreach message populate the corresponding sender and recipient', async () => {
jest.spyOn(messageService, 'findPageAndPopulate');
const result = await messageController.findPage(
pageQuery,
['sender', 'recipient'],
{},
);
const messages = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']),
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
expect(messageService.findPageAndPopulate).toHaveBeenCalledWith(
{},
pageQuery,
);
expect(result).toEqualPayload(messages);
});
});
});

View File

@@ -0,0 +1,169 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
BadRequestException,
Body,
Controller,
Get,
NotFoundException,
Param,
Post,
Query,
Req,
UseInterceptors,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { Request } from 'express'; // Import the Express request and response types
import { TFilterQuery } from 'mongoose';
import { ChannelService } from '@/channel/channel.service';
import { GenericEventWrapper } from '@/channel/lib/EventWrapper';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseController } from '@/utils/generics/base-controller';
import { BaseSchema } from '@/utils/generics/base-schema';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { MessageCreateDto } from '../dto/message.dto';
import { Message, MessageStub } from '../schemas/message.schema';
import {
OutgoingMessage,
OutgoingMessageFormat,
StdOutgoingEnvelope,
StdOutgoingMessage,
StdOutgoingTextMessage,
} from '../schemas/types/message';
import { MessageService } from '../services/message.service';
import { SubscriberService } from '../services/subscriber.service';
@UseInterceptors(CsrfInterceptor)
@Controller('message')
export class MessageController extends BaseController<Message, MessageStub> {
constructor(
private readonly messageService: MessageService,
private readonly subscriberService: SubscriberService,
private readonly channelService: ChannelService,
private readonly logger: LoggerService,
private readonly eventEmitter: EventEmitter2,
) {
super(messageService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Message>,
@Query(PopulatePipe)
populate: string[],
@Query(
new SearchFilterPipe<Message>({ allowedFields: ['recipient', 'sender'] }),
)
filters: TFilterQuery<Message>,
) {
return this.canPopulate(populate, ['recipient', 'sender', 'sentBy'])
? await this.messageService.findPageAndPopulate(filters, pageQuery)
: await this.messageService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of messages.
* @returns A promise that resolves to an object representing the filtered number of messages.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Message>({
allowedFields: ['recipient', 'sender'],
}),
)
filters?: TFilterQuery<Message>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
) {
const doc = this.canPopulate(populate, ['recipient', 'sender', 'sentBy'])
? await this.messageService.findOneAndPopulate(id)
: await this.messageService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Message by id ${id}`);
throw new NotFoundException(`Message with ID ${id} not found`);
}
return doc;
}
@CsrfCheck(true)
@Post()
async create(@Body() messageDto: MessageCreateDto, @Req() req: Request) {
//TODO : Investigate if recipient and inReplyTo should be updated to required in dto
if (!messageDto.recipient || !messageDto.inReplyTo) {
throw new BadRequestException('MessageController send : invalid params');
}
const subscriber = await this.subscriberService.findOne(
messageDto.recipient,
);
if (!subscriber) {
this.logger.warn(
`Unable to find subscriber by id ${messageDto.recipient}`,
);
throw new NotFoundException(
`Subscriber with ID ${messageDto.recipient} not found`,
);
}
if (!this.channelService.findChannel(subscriber?.channel.name)) {
throw new BadRequestException(`Subscriber channel not found`);
}
const envelope: StdOutgoingEnvelope = {
format: OutgoingMessageFormat.text,
message: messageDto.message as StdOutgoingTextMessage,
};
const channelHandler = this.channelService.getChannelHandler(
subscriber.channel.name,
);
const event = new GenericEventWrapper(channelHandler, {
senderId: subscriber.foreign_id,
messageId: messageDto.inReplyTo,
});
event.setSender(subscriber);
try {
const { mid } = await channelHandler.sendMessage(event, envelope, {}, {});
// Trigger sent message event
const sentMessage: Omit<OutgoingMessage, keyof BaseSchema> = {
mid,
recipient: subscriber.id,
message: messageDto.message as StdOutgoingMessage,
sentBy: req.session?.passport?.user.id,
read: false,
delivery: false,
};
this.eventEmitter.emit('hook:chatbot:sent', sentMessage);
return {
success: true,
};
} catch (err) {
this.logger.debug('MessageController send : Unable to send message', err);
throw new BadRequestException(
'MessageController send : unable to send message',
);
}
}
}

View File

@@ -0,0 +1,191 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel, User } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import {
installSubscriberFixtures,
subscriberFixtures,
} from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { SocketEventDispatcherService } from '@/websocket/services/socket-event-dispatcher.service';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { UserService } from './../../user/services/user.service';
import { LabelService } from './../services/label.service';
import { SubscriberController } from './subscriber.controller';
import { LabelRepository } from '../repositories/label.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { LabelModel, Label } from '../schemas/label.schema';
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
import { SubscriberService } from '../services/subscriber.service';
describe('SubscriberController', () => {
let subscriberController: SubscriberController;
let subscriberService: SubscriberService;
let labelService: LabelService;
let userService: UserService;
let subscriber: Subscriber;
let allLabels: Label[];
let allSubscribers: Subscriber[];
let allUsers: User[];
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [SubscriberController],
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([
SubscriberModel,
LabelModel,
UserModel,
RoleModel,
PermissionModel,
AttachmentModel,
]),
],
providers: [
LoggerService,
SubscriberRepository,
SubscriberService,
LabelService,
LabelRepository,
UserService,
WebsocketGateway,
SocketEventDispatcherService,
UserRepository,
RoleService,
RoleRepository,
EventEmitter2,
AttachmentService,
AttachmentRepository,
],
}).compile();
subscriberService = module.get<SubscriberService>(SubscriberService);
labelService = module.get<LabelService>(LabelService);
userService = module.get<UserService>(UserService);
subscriberController =
module.get<SubscriberController>(SubscriberController);
subscriber = await subscriberService.findOne({
first_name: 'Jhon',
});
allLabels = await labelService.findAll();
allSubscribers = await subscriberService.findAll();
allUsers = await userService.findAll();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count subscribers', async () => {
jest.spyOn(subscriberService, 'count');
const result = await subscriberController.filterCount();
expect(subscriberService.count).toHaveBeenCalled();
expect(result).toEqual({ count: subscriberFixtures.length });
});
});
describe('findOne', () => {
it('should find one subscriber by id', async () => {
jest.spyOn(subscriberService, 'findOne');
const result = await subscriberService.findOne(subscriber.id);
const labelIDs = allLabels
.filter((label) => subscriber.labels.includes(label.id))
.map(({ id }) => id);
expect(subscriberService.findOne).toHaveBeenCalledWith(subscriber.id);
expect(result).toEqualPayload({
...subscriberFixtures.find(
({ first_name }) => first_name === subscriber.first_name,
),
labels: labelIDs,
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id).id,
});
});
it('should find one subscriber by id, and populate its corresponding labels', async () => {
jest.spyOn(subscriberService, 'findOneAndPopulate');
const result = await subscriberController.findOne(subscriber.id, [
'labels',
]);
expect(subscriberService.findOneAndPopulate).toHaveBeenCalledWith(
subscriber.id,
);
expect(result).toEqualPayload({
...subscriberFixtures.find(
({ first_name }) => first_name === subscriber.first_name,
),
labels: allLabels.filter((label) =>
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
});
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Subscriber>();
it('should find subscribers', async () => {
jest.spyOn(subscriberService, 'findPage');
const result = await subscriberController.findPage(pageQuery, [], {});
const subscribersWithIds = allSubscribers.map(({ labels, ...rest }) => ({
...rest,
labels: allLabels
.filter((label) => labels.includes(label.id))
.map(({ id }) => id),
}));
expect(subscriberService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(subscribersWithIds.sort(sortRowsBy));
});
it('should find subscribers, and foreach subscriber populate the corresponding labels', async () => {
jest.spyOn(subscriberService, 'findPageAndPopulate');
const result = await subscriberController.findPage(
pageQuery,
['labels'],
{},
);
const subscribersWithLabels = allSubscribers.map(
({ labels, ...rest }) => ({
...rest,
labels: allLabels.filter((label) => labels.includes(label.id)),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
}),
);
expect(subscriberService.findPageAndPopulate).toHaveBeenCalledWith(
{},
pageQuery,
);
expect(result).toEqualPayload(subscribersWithLabels.sort(sortRowsBy));
});
});
});

View File

@@ -0,0 +1,146 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Patch,
Query,
StreamableFile,
UseInterceptors,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { Roles } from '@/utils/decorators/roles.decorator';
import { BaseController } from '@/utils/generics/base-controller';
import { generateInitialsAvatar } from '@/utils/helpers/avatar';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
import { Subscriber, SubscriberStub } from '../schemas/subscriber.schema';
import { SubscriberService } from '../services/subscriber.service';
@UseInterceptors(CsrfInterceptor)
@Controller('subscriber')
export class SubscriberController extends BaseController<
Subscriber,
SubscriberStub
> {
constructor(
private readonly subscriberService: SubscriberService,
private readonly logger: LoggerService,
) {
super(subscriberService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Subscriber>,
@Query(PopulatePipe)
populate: string[],
@Query(
new SearchFilterPipe<Subscriber>({
// TODO : Check if the field email should be added to Subscriber schema
allowedFields: [
'first_name',
'last_name',
'assignedTo',
'labels',
'channel.name',
],
}),
)
filters: TFilterQuery<Subscriber>,
) {
return this.canPopulate(populate, ['labels', 'assignedTo', 'avatar'])
? await this.subscriberService.findPageAndPopulate(filters, pageQuery)
: await this.subscriberService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of subscribers.
* @returns A promise that resolves to an object representing the filtered number of subscribers.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Subscriber>({
allowedFields: [
'first_name',
'last_name',
'assignedTo',
'labels',
'channel.name',
],
}),
)
filters?: TFilterQuery<Subscriber>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
) {
const doc = this.canPopulate(populate, ['labels', 'assignedTo', 'avatar'])
? await this.subscriberService.findOneAndPopulate(id)
: await this.subscriberService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Subscriber by id ${id}`);
throw new NotFoundException(`Subscriber with ID ${id} not found`);
}
return doc;
}
@Roles('public')
@Get(':foreign_id/profile_pic')
async findProfilePic(
@Param('foreign_id') foreign_id: string,
): Promise<StreamableFile> {
try {
const pic = await this.subscriberService.findProfilePic(foreign_id);
return pic;
} catch (e) {
const [subscriber] = await this.subscriberService.find({ foreign_id });
if (subscriber) {
return generateInitialsAvatar(subscriber);
} else {
throw new NotFoundException(
`Subscriber with ID ${foreign_id} not found`,
);
}
}
}
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() subscriberUpdate: SubscriberUpdateDto,
) {
const result = await this.subscriberService.updateOne(id, subscriberUpdate);
if (!result) {
this.logger.warn(`Unable to update Subscriber by id ${id}`);
throw new NotFoundException(`Subscriber with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,205 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ChannelService } from '@/channel/channel.service';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { MenuRepository } from '@/cms/repositories/menu.repository';
import { ContentModel } from '@/cms/schemas/content.schema';
import { MenuModel } from '@/cms/schemas/menu.schema';
import { ContentService } from '@/cms/services/content.service';
import { MenuService } from '@/cms/services/menu.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import {
installTranslationFixtures,
translationFixtures,
} from '@/utils/test/fixtures/translation';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MessageController } from './message.controller';
import { TranslationController } from './translation.controller';
import { TranslationUpdateDto } from '../dto/translation.dto';
import { BlockRepository } from '../repositories/block.repository';
import { MessageRepository } from '../repositories/message.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { TranslationRepository } from '../repositories/translation.repository';
import { BlockModel } from '../schemas/block.schema';
import { MessageModel } from '../schemas/message.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { Translation, TranslationModel } from '../schemas/translation.schema';
import { BlockService } from '../services/block.service';
import { MessageService } from '../services/message.service';
import { SubscriberService } from '../services/subscriber.service';
import { TranslationService } from '../services/translation.service';
describe('TranslationController', () => {
let translationController: TranslationController;
let translationService: TranslationService;
let translation: Translation;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [MessageController],
imports: [
rootMongooseTestModule(installTranslationFixtures),
MongooseModule.forFeature([
SubscriberModel,
TranslationModel,
MessageModel,
AttachmentModel,
MenuModel,
BlockModel,
ContentModel,
]),
],
providers: [
TranslationController,
TranslationService,
TranslationRepository,
MessageService,
MessageRepository,
SubscriberService,
SubscriberRepository,
ChannelService,
AttachmentService,
AttachmentRepository,
MenuService,
MenuRepository,
{
provide: NlpService,
useValue: {
getNLP: jest.fn(() => undefined),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({})),
},
},
BlockService,
BlockRepository,
ContentService,
ContentRepository,
{
provide: PluginService,
useValue: {},
},
EventEmitter2,
LoggerService,
{
provide: ExtendedI18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
initDynamicTranslations: jest.fn(),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
],
}).compile();
translationService = module.get<TranslationService>(TranslationService);
translationController = module.get<TranslationController>(
TranslationController,
);
translation = await translationService.findOne({ str: 'Welcome' });
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count translations', async () => {
jest.spyOn(translationService, 'count');
const result = await translationController.filterCount();
expect(translationService.count).toHaveBeenCalled();
expect(result).toEqual({ count: translationFixtures.length });
});
});
describe('findOne', () => {
it('should find one translation by id', async () => {
jest.spyOn(translationService, 'findOne');
const result = await translationController.findOne(translation.id);
expect(translationService.findOne).toHaveBeenCalledWith(translation.id);
expect(result).toEqualPayload(
translationFixtures.find(({ str }) => str === translation.str),
);
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Translation>();
it('should find translations', async () => {
jest.spyOn(translationService, 'findPage');
const result = await translationController.findPage(pageQuery, {});
expect(translationService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(translationFixtures);
});
});
describe('updateOne', () => {
const translationUpdateDto: TranslationUpdateDto = {
str: 'Welcome !',
};
it('should update one translation by id', async () => {
jest.spyOn(translationService, 'updateOne');
const result = await translationController.updateOne(
translation.id,
translationUpdateDto,
);
expect(translationService.updateOne).toHaveBeenCalledWith(
translation.id,
translationUpdateDto,
);
expect(result).toEqualPayload({
...translationFixtures.find(({ str }) => str === translation.str),
...translationUpdateDto,
});
});
it('should throw a NotFoundException when attempting to update a translation by id', async () => {
jest.spyOn(translationService, 'updateOne');
await expect(
translationController.updateOne(NOT_FOUND_ID, translationUpdateDto),
).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,142 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Patch,
Query,
UseInterceptors,
Post,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { SettingService } from '@/setting/services/setting.service';
import { BaseController } from '@/utils/generics/base-controller';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { TranslationUpdateDto } from '../dto/translation.dto';
import { Translation } from '../schemas/translation.schema';
import { TranslationService } from '../services/translation.service';
@UseInterceptors(CsrfInterceptor)
@Controller('translation')
export class TranslationController extends BaseController<Translation> {
constructor(
private readonly translationService: TranslationService,
private readonly settingService: SettingService,
private readonly logger: LoggerService,
) {
super(translationService);
}
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Translation>,
@Query(new SearchFilterPipe<Translation>({ allowedFields: ['str'] }))
filters: TFilterQuery<Translation>,
) {
return await this.translationService.findPage(filters, pageQuery);
}
/**
* Counts the filtered number of translations.
* @returns A promise that resolves to an object representing the filtered number of translations.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Translation>({
allowedFields: ['str'],
}),
)
filters?: TFilterQuery<Translation>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(@Param('id') id: string) {
const doc = await this.translationService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Translation by id ${id}`);
throw new NotFoundException(`Translation with ID ${id} not found`);
}
return doc;
}
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() translationUpdate: TranslationUpdateDto,
) {
const result = await this.translationService.updateOne(
id,
translationUpdate,
);
if (!result) {
this.logger.warn(`Unable to update Translation by id ${id}`);
throw new NotFoundException(`Translation with ID ${id} not found`);
}
return result;
}
/**
* Refresh translations : Add new strings and remove old ones
* @returns {Promise<any>}
*/
@CsrfCheck(true)
@Post('refresh')
async refresh(): Promise<any> {
const settings = await this.settingService.getSettings();
const languages = settings.nlp_settings.languages;
const defaultTrans: Translation['translations'] = languages.reduce(
(acc, curr) => {
acc[curr] = '';
return acc;
},
{} as { [key: string]: string },
);
// Scan Blocks
return this.translationService
.getAllBlockStrings()
.then(async (strings: string[]) => {
const settingStrings =
await this.translationService.getSettingStrings();
// Scan global settings
strings = strings.concat(settingStrings);
// Filter unique and not empty messages
strings = strings.filter((str, pos) => {
return str && strings.indexOf(str) == pos;
});
// Perform refresh
const queue = strings.map((str) =>
this.translationService.findOneOrCreate(
{ str },
{ str, translations: defaultTrans as any, translated: 100 },
),
);
return Promise.all(queue).then(() => {
// Purge non existing translations
return this.translationService.deleteMany({
str: { $nin: strings },
});
});
});
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { CaptureVar } from '../schemas/types/capture-var';
import { BlockMessage } from '../schemas/types/message';
import { BlockOptions } from '../schemas/types/options';
import { Pattern } from '../schemas/types/pattern';
import { Position } from '../schemas/types/position';
import { IsMessage } from '../validation-rules/is-message';
import { IsPatternList } from '../validation-rules/is-pattern-list';
import { IsPosition } from '../validation-rules/is-position';
import { IsVarCapture } from '../validation-rules/is-valid-capture';
export class BlockCreateDto {
@ApiProperty({ description: 'Block name', type: String })
@IsNotEmpty()
@IsString()
name: string;
@ApiPropertyOptional({ description: 'Block patterns', type: Array })
@IsOptional()
@IsPatternList({ message: 'Patterns list is invalid' })
patterns?: Pattern[] = [];
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Trigger label must be a valid objectId' })
trigger_labels?: string[] = [];
@ApiPropertyOptional({ description: 'Block assign labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Assign label must be a valid objectId' })
assign_labels?: string[] = [];
@ApiPropertyOptional({ description: 'Block trigger channels', type: Array })
@IsOptional()
@IsArray()
trigger_channels?: string[] = [];
@ApiPropertyOptional({ description: 'Block options', type: Object })
@IsOptional()
@IsObject()
options?: BlockOptions;
@ApiProperty({ description: 'Block message', type: Object })
@IsNotEmpty()
@IsMessage({ message: 'Message is invalid' })
message: BlockMessage;
@ApiPropertyOptional({ description: 'next blocks', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Next block must be a valid objectId' })
nextBlocks?: string[];
@ApiPropertyOptional({ description: 'attached blocks', type: String })
@IsOptional()
@IsString()
@IsObjectId({
message: 'Attached block must be a valid objectId',
})
attachedBlock?: string;
@ApiProperty({ description: 'Block category', type: String })
@IsNotEmpty()
@IsString()
@IsObjectId({ message: 'Category must be a valid objectId' })
category: string;
@ApiPropertyOptional({
description: 'Block has started conversation',
type: Boolean,
})
@IsBoolean()
@IsOptional()
starts_conversation?: boolean;
@ApiPropertyOptional({
description: 'Block capture vars',
type: Array,
})
@IsOptional()
@IsVarCapture({ message: 'Capture vars are invalid' })
capture_vars?: CaptureVar[];
@ApiProperty({
description: 'Block position',
type: Object,
})
@IsNotEmpty()
@IsPosition({ message: 'Position is invalid' })
position: Position;
}
export class BlockUpdateDto extends PartialType(
OmitType(BlockCreateDto, [
'patterns',
'trigger_labels',
'assign_labels',
'trigger_channels',
]),
) {
@ApiPropertyOptional({ description: 'Block patterns', type: Array })
@IsOptional()
@IsPatternList({ message: 'Patterns list is invalid' })
patterns?: Pattern[];
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Trigger label must be a valid objectId' })
trigger_labels?: string[];
@ApiPropertyOptional({ description: 'Block assign labels', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({ each: true, message: 'Assign label must be a valid objectId' })
assign_labels?: string[];
@ApiPropertyOptional({ description: 'Block trigger channels', type: Array })
@IsArray()
@IsOptional()
trigger_channels?: string[];
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsNumber,
IsArray,
} from 'class-validator';
export class CategoryCreateDto {
@ApiProperty({ description: 'Category label', type: String })
@IsNotEmpty()
@IsString()
label: string;
@ApiPropertyOptional({ description: 'Category is builtin', type: Boolean })
@IsOptional()
@IsBoolean()
builtin?: boolean;
@ApiPropertyOptional({ description: 'Zoom', type: Number })
@IsOptional()
@IsNumber()
zoom?: number;
@ApiPropertyOptional({ description: 'Offset', type: Array })
@IsOptional()
@IsArray()
offset?: [number, number];
}
export class CategoryUpdateDto extends PartialType(CategoryCreateDto) {}

View File

@@ -0,0 +1,25 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class ContextVarCreateDto {
@ApiProperty({ description: 'Context var label', type: String })
@IsNotEmpty()
@IsString()
label: string;
@ApiProperty({ description: 'Context var name', type: String })
@IsNotEmpty()
@IsString()
name: string;
}
export class ContextVarUpdateDto extends PartialType(ContextVarCreateDto) {}

View File

@@ -0,0 +1,59 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { Context } from './../schemas/types/context';
export class ConversationCreateDto {
@ApiProperty({ description: 'Conversation sender', type: String })
@IsNotEmpty()
@IsString()
@IsObjectId({
message: 'Sender must be a valid objectId',
})
sender: string;
@ApiPropertyOptional({ description: 'Conversation is active', type: Boolean })
@IsBoolean()
@IsOptional()
active?: boolean;
@ApiPropertyOptional({ description: 'Conversation context', type: Object })
@IsOptional()
@IsObject()
context?: Context;
@ApiProperty({ description: 'Current conversation', type: String })
@IsOptional()
@IsString()
@IsObjectId({
message: 'Current must be a valid objectId',
})
current: string;
@ApiProperty({ description: 'next conversation', type: Array })
@IsOptional()
@IsArray()
@IsObjectId({
each: true,
message: 'next must be a valid objectId',
})
next: string[];
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
IsObject,
} from 'class-validator';
export class LabelCreateDto {
@ApiProperty({ description: 'Label title', type: String })
@IsNotEmpty()
@IsString()
title: string;
@ApiProperty({ description: 'Label name', type: String })
@IsNotEmpty()
@IsString()
@Matches(/^[A-Z_0-9]+$/)
name: string;
@ApiPropertyOptional({ description: 'Label description', type: String })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Label id', type: Object })
@IsOptional()
@IsObject()
label_id?: Record<string, any>;
}
export class LabelUpdateDto extends PartialType(LabelCreateDto) {}

View File

@@ -0,0 +1,80 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsObject,
IsString,
IsOptional,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import {
StdIncomingMessage,
StdOutgoingMessage,
} from '../schemas/types/message';
import { IsValidMessageText } from '../validation-rules/is-valid-message-text';
export class MessageCreateDto {
@ApiProperty({ description: 'Message id', type: String })
@IsOptional()
@IsString()
mid?: string;
@ApiProperty({ description: 'Reply to Message id', type: String })
@IsOptional()
@IsString()
inReplyTo?: string;
@ApiPropertyOptional({ description: 'Message sender', type: String })
@IsString()
@IsOptional()
@IsObjectId({ message: 'Sender must be a valid ObjectId' })
sender?: string;
@ApiPropertyOptional({ description: 'Message recipient', type: String })
@IsString()
@IsOptional()
@IsObjectId({ message: 'Recipient must be a valid ObjectId' })
recipient?: string;
@ApiPropertyOptional({ description: 'Message sent by', type: String })
@IsString()
@IsOptional()
@IsObjectId({ message: 'SentBy must be a valid ObjectId' })
sentBy?: string;
@ApiProperty({ description: 'Message', type: Object })
@IsObject()
@IsNotEmpty()
@IsValidMessageText({ message: 'Message should have text property' })
message: StdOutgoingMessage | StdIncomingMessage;
@ApiPropertyOptional({ description: 'Message is read', type: Boolean })
@IsBoolean()
@IsNotEmpty()
@IsOptional()
read?: boolean;
@ApiPropertyOptional({ description: 'Message is delivered', type: Boolean })
@IsBoolean()
@IsNotEmpty()
@IsOptional()
delivery?: boolean;
@ApiPropertyOptional({ description: 'Message is handed over', type: Boolean })
@IsBoolean()
@IsOptional()
handover?: boolean;
}
export class MessageUpdateDto extends PartialType(MessageCreateDto) {}

View File

@@ -0,0 +1,116 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
IsArray,
IsNotEmpty,
IsNumber,
IsString,
IsOptional,
IsDate,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { ChannelData } from '../schemas/types/channel';
import { IsChannelData } from '../validation-rules/is-channel-data';
export class SubscriberCreateDto {
@ApiProperty({ description: 'Subscriber first name', type: String })
@IsNotEmpty()
@IsString()
first_name: string;
@ApiProperty({ description: 'Subscriber last name', type: String })
@IsNotEmpty()
@IsString()
last_name: string;
@ApiPropertyOptional({ description: 'Subscriber locale', type: String })
@IsOptional()
@IsString()
locale: string;
@ApiPropertyOptional({ description: 'Subscriber timezone', type: Number })
@IsOptional()
@IsNumber()
timezone?: number;
@ApiPropertyOptional({ description: 'Subscriber language', type: String })
@IsNotEmpty()
@IsString()
language: string;
@ApiPropertyOptional({ description: 'Subscriber gender', type: String })
@IsOptional()
@IsString()
gender: string;
@ApiPropertyOptional({ description: 'Subscriber country', type: String })
@IsOptional()
@IsString()
country: string;
@ApiPropertyOptional({ description: 'Subscriber foreign id', type: String })
@IsOptional()
@IsString()
foreign_id: string;
@ApiProperty({ description: 'Subscriber labels', type: Array })
@IsNotEmpty()
@IsArray()
@IsObjectId({ each: true, message: 'Label must be a valid ObjectId' })
labels: string[];
@ApiPropertyOptional({
description: 'Subscriber assigned to',
type: String,
default: null,
})
@IsOptional()
@IsString()
@IsObjectId({ message: 'AssignedTo must be a valid ObjectId' })
assignedTo?: string | null;
@ApiPropertyOptional({
description: 'Subscriber assigned at',
type: Date,
default: null,
})
@IsOptional()
@IsDate()
assignedAt: Date | null;
@ApiPropertyOptional({
description: 'Subscriber last visit',
type: Date,
})
@IsOptional()
@IsDate()
lastvisit: Date;
@ApiPropertyOptional({
description: 'Subscriber retained from',
type: Date,
})
@IsOptional()
@IsDate()
retainedFrom: Date;
@ApiProperty({
description: 'Subscriber channel',
type: Object,
})
@IsNotEmpty()
@IsChannelData()
channel: ChannelData;
}
export class SubscriberUpdateDto extends PartialType(SubscriberCreateDto) {}

View File

@@ -0,0 +1,48 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsNotEmpty,
IsObject,
IsString,
IsOptional,
IsNumber,
} from 'class-validator';
export class TranslationCreateDto {
@ApiProperty({ description: 'Translation str', type: String })
@IsNotEmpty()
@IsString()
str: string;
@ApiProperty({ description: 'Translations', type: Object })
@IsNotEmpty()
@IsObject()
translations: Record<string, string>;
@ApiProperty({ description: 'Translated', type: Number })
@IsNotEmpty()
@IsNumber()
translated: number;
}
export class TranslationUpdateDto {
@ApiPropertyOptional({ description: 'Translation str', type: String })
@IsNotEmpty()
@IsString()
@IsOptional()
str?: string;
@ApiPropertyOptional({ description: 'Translations', type: Object })
@IsNotEmpty()
@IsObject()
@IsOptional()
translations?: Record<string, string>;
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
/**
* VIEW_MORE_PAYLOAD is declared, but never used.
*/
export const VIEW_MORE_PAYLOAD = 'VIEW_MORE';

View File

@@ -0,0 +1,109 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import {
blockFixtures,
installBlockFixtures,
} from '@/utils/test/fixtures/block';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { BlockRepository } from './block.repository';
import { CategoryRepository } from './category.repository';
import { BlockModel, Block } from '../schemas/block.schema';
import { CategoryModel, Category } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
describe('BlockRepository', () => {
let blockRepository: BlockRepository;
let categoryRepository: CategoryRepository;
let blockModel: Model<Block>;
let category: Category;
let hasPreviousBlocks: Block;
let hasNextBlocks: Block;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installBlockFixtures),
MongooseModule.forFeature([BlockModel, CategoryModel, LabelModel]),
],
providers: [BlockRepository, CategoryRepository],
}).compile();
blockRepository = module.get<BlockRepository>(BlockRepository);
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
blockModel = module.get<Model<Block>>(getModelToken('Block'));
category = await categoryRepository.findOne({ label: 'default' });
hasPreviousBlocks = await blockRepository.findOne({
name: 'hasPreviousBlocks',
});
hasNextBlocks = await blockRepository.findOne({
name: 'hasNextBlocks',
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one block by id, and populate its trigger_labels, assign_labels, nextBlocks, attachedBlock, category,previousBlocks', async () => {
jest.spyOn(blockModel, 'findById');
const result = await blockRepository.findOneAndPopulate(hasNextBlocks.id);
expect(blockModel.findById).toHaveBeenCalledWith(hasNextBlocks.id);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === hasNextBlocks.name),
category,
nextBlocks: [hasPreviousBlocks],
previousBlocks: [],
});
});
});
describe('findAndPopulate', () => {
it('should find blocks, and foreach block populate its trigger_labels, assign_labels, attachedBlock, category, previousBlocks', async () => {
jest.spyOn(blockModel, 'find');
const category = await categoryRepository.findOne({ label: 'default' });
const result = await blockRepository.findAndPopulate({});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
}));
expect(blockModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory);
});
it('should find blocks, and foreach block populate its trigger_labels, assign_labels, nextBlocks, attachedBlock, category', async () => {
jest.spyOn(blockModel, 'find');
const category = await categoryRepository.findOne({ label: 'default' });
const result = await blockRepository.findAndPopulate({});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [hasNextBlocks] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
}));
expect(blockModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(blocksWithCategory);
});
});
});

View File

@@ -0,0 +1,203 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
TFilterQuery,
Model,
Document,
Types,
Query,
UpdateQuery,
UpdateWithAggregationPipeline,
} from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto';
import { Block, BlockFull } from '../schemas/block.schema';
@Injectable()
export class BlockRepository extends BaseRepository<
Block,
| 'trigger_labels'
| 'assign_labels'
| 'nextBlocks'
| 'attachedBlock'
| 'category'
| 'previousBlocks'
| 'attachedToBlock'
> {
private readonly logger: LoggerService;
constructor(
@InjectModel(Block.name) readonly model: Model<Block>,
@Optional() logger?: LoggerService,
) {
super(model, Block);
this.logger = logger;
}
/**
* Checks if the `url` field in the attachment payload is deprecated, and logs an error if found.
*
* @param block - The block DTO (create or update) to check.
*/
checkDeprecatedAttachmentUrl(block: BlockCreateDto | BlockUpdateDto) {
if (
block.message &&
'attachment' in block.message &&
'url' in block.message.attachment.payload
) {
this.logger.error(
'NOTE: `url` payload has been deprecated in favor of `attachment_id`',
block.name,
);
}
}
/**
* Pre-processing logic for creating a new block.
*
* @param doc - The document that is being created.
*/
async preCreate(
_doc: Document<unknown, object, Block> & Block & { _id: Types.ObjectId },
): Promise<void> {
if (_doc) this.checkDeprecatedAttachmentUrl(_doc);
}
/**
* Pre-processing logic for updating a block.
*
* @param query - The query to update a block.
* @param criteria - The filter criteria for the update query.
* @param updates - The update data.
*/
async preUpdate(
_query: Query<
Document<Block, any, any>,
Document<Block, any, any>,
unknown,
Block,
'findOneAndUpdate'
>,
_criteria: TFilterQuery<Block>,
_updates:
| UpdateWithAggregationPipeline
| UpdateQuery<Document<Block, any, any>>,
): Promise<void> {
const updates: BlockUpdateDto = _updates?.['$set'];
this.checkDeprecatedAttachmentUrl(updates);
}
/**
* Post-processing logic after deleting a block.
*
* @param query - The delete query.
* @param result - The result of the delete operation.
*/
async postDelete(
_query: Query<
DeleteResult,
Document<Block, any, any>,
unknown,
Block,
'deleteOne' | 'deleteMany'
>,
result: DeleteResult,
) {
if (result.deletedCount > 0) {
}
}
/**
* Pre-processing logic before deleting a block.
* It handles removing references to the block from other related blocks.
*
* @param query - The delete query.
* @param criteria - The filter criteria for finding blocks to delete.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Block, any, any>,
unknown,
Block,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<Block>,
) {
const docsToDelete = await this.model.find(criteria);
const idsToDelete = docsToDelete.map(({ id }) => id);
if (idsToDelete.length > 0) {
// Remove from all other blocks
await this.model.updateMany(
{ attachedBlock: { $in: idsToDelete } },
{
$set: {
attachedBlock: null,
},
},
);
// Remove all other previous blocks
await this.model.updateMany(
{ nextBlocks: { $in: idsToDelete } },
{
$pull: {
nextBlocks: { $in: idsToDelete },
},
},
);
}
}
/**
* Finds blocks and populates related fields (e.g., labels, attached blocks).
*
* @param filters - The filter criteria for finding blocks.
*
* @returns The populated block results.
*/
async findAndPopulate(filters: TFilterQuery<Block>) {
const query = this.findQuery(filters).populate([
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
]);
return await this.execute(query, BlockFull);
}
/**
* Finds a single block by ID and populates related fields (e.g., labels, attached blocks).
*
* @param id - The ID of the block to find.
*
* @returns The populated block result or null if not found.
*/
async findOneAndPopulate(id: string) {
const query = this.findOneQuery(id).populate([
'trigger_labels',
'assign_labels',
'nextBlocks',
'attachedBlock',
'category',
'previousBlocks',
'attachedToBlock',
]);
return await this.executeOne(query, BlockFull);
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ForbiddenException, Injectable, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query, TFilterQuery } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { Category } from '../schemas/category.schema';
import { BlockService } from '../services/block.service';
@Injectable()
export class CategoryRepository extends BaseRepository<Category> {
private readonly logger: LoggerService;
private readonly blockService: BlockService;
constructor(
@InjectModel(Category.name) readonly model: Model<Category>,
@Optional() blockService?: BlockService,
@Optional() logger?: LoggerService,
) {
super(model, Category);
this.logger = logger;
this.blockService = blockService;
}
/**
* Pre-processing logic before deleting a category.
* It avoids delete a category that contains blocks.
*
* @param query - The delete query.
* @param criteria - The filter criteria for finding blocks to delete.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Category, any, any>,
unknown,
Category,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<Category>,
) {
const associatedBlocks = await this.blockService.findOne({
category: criteria._id,
});
if (associatedBlocks) {
throw new ForbiddenException(`Category have blocks associated to it`);
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { ContextVar } from '../schemas/context-var.schema';
@Injectable()
export class ContextVarRepository extends BaseRepository<ContextVar> {
constructor(@InjectModel(ContextVar.name) readonly model: Model<ContextVar>) {
super(model, ContextVar);
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import {
Conversation,
ConversationDocument,
ConversationFull,
} from '../schemas/conversation.schema';
@Injectable()
export class ConversationRepository extends BaseRepository<
Conversation,
'sender' | 'current' | 'next'
> {
constructor(
@InjectModel(Conversation.name) readonly model: Model<Conversation>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Conversation);
}
/**
* Called after a new conversation is created. This method emits the event
* with the newly created conversation document.
*
* @param created - The newly created conversation document.
*/
async postCreate(created: ConversationDocument): Promise<void> {
this.eventEmitter.emit('hook:chatbot:conversation:start', created);
}
/**
* Marks a conversation as ended by setting its `active` status to `false`.
*
* @param convo The conversation or full conversation object to be ended.
*
* @returns A promise resolving to the result of the update operation.
*/
async end(convo: Conversation | ConversationFull) {
return await this.updateOne(convo.id, { active: false });
}
/**
* Finds a single conversation by a given criteria and populates the related fields: `sender`, `current`, and `next`.
*
* @param criteria The search criteria, either a string or a filter query.
*
* @returns A promise resolving to the populated conversation full object.
*/
async findOneAndPopulate(criteria: string | FilterQuery<Conversation>) {
const query = this.findOneQuery(criteria).populate([
'sender',
'current',
'next',
]);
return await this.executeOne(query, ConversationFull);
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { labelFixtures } from '@/utils/test/fixtures/label';
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LabelRepository } from './label.repository';
import { SubscriberRepository } from './subscriber.repository';
import { LabelModel, Label } from '../schemas/label.schema';
import { SubscriberModel, Subscriber } from '../schemas/subscriber.schema';
describe('LabelRepository', () => {
let labelRepository: LabelRepository;
let labelModel: Model<Label>;
let subscriberRepository: SubscriberRepository;
let users: Subscriber[];
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([LabelModel, SubscriberModel]),
],
providers: [
LabelRepository,
SubscriberRepository,
EventEmitter2,
LoggerService,
],
}).compile();
labelRepository = module.get<LabelRepository>(LabelRepository);
subscriberRepository =
module.get<SubscriberRepository>(SubscriberRepository);
labelModel = module.get<Model<Label>>(getModelToken('Label'));
users = await subscriberRepository.findAll();
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one label by id, and populate its users', async () => {
jest.spyOn(labelModel, 'findById');
const label = await labelRepository.findOne({ name: 'TEST_TITLE_2' });
const result = await labelRepository.findOneAndPopulate(label.id);
expect(labelModel.findById).toHaveBeenCalledWith(label.id);
expect(result).toEqualPayload({
...labelFixtures.find(({ name }) => name === label.name),
users,
});
});
});
describe('findAllAndPopulate', () => {
it('should find all labels, and foreach label populate its corresponding users', async () => {
jest.spyOn(labelModel, 'find');
const result = await labelRepository.findAllAndPopulate();
const labelsWithUsers = labelFixtures.map((label) => ({
...label,
users,
}));
expect(labelModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(labelsWithUsers);
});
});
describe('findPageAndPopulate', () => {
it('should find labels, and foreach label populate its corresponding users', async () => {
const pageQuery = getPageQuery<Label>();
jest.spyOn(labelModel, 'find');
const result = await labelRepository.findPageAndPopulate({}, pageQuery);
const labelsWithUsers = labelFixtures.map((label) => ({
...label,
users,
}));
expect(labelModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(labelsWithUsers.sort(sortRowsBy));
});
});
});

View File

@@ -0,0 +1,119 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { TFilterQuery, Model, Document, Query } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { Label, LabelDocument, LabelFull } from '../schemas/label.schema';
@Injectable()
export class LabelRepository extends BaseRepository<Label, 'users'> {
constructor(
@InjectModel(Label.name) readonly model: Model<Label>,
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService,
) {
super(model, Label);
}
/**
* After creating a `Label`, this method emits an event and updates the `label_id` field.
*
* @param created - The created label document instance.
*
* @returns A promise that resolves when the update operation is complete.
*/
async postCreate(created: LabelDocument): Promise<void> {
this.eventEmitter.emit(
'hook:chatbot:label:create',
created,
async (result: Record<string, any>) => {
await this.model.updateOne(
{ _id: created._id },
{
$set: {
label_id: {
...(created.label_id || {}),
...result,
},
},
},
);
},
);
}
/**
* Before deleting a label, this method fetches the label(s) based on the given criteria and emits a delete event.
*
* @param query - The Mongoose query object used for deletion.
* @param criteria - The filter criteria for finding the labels to be deleted.
*
* @returns {Promise<void>} A promise that resolves once the event is emitted.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Label, any, any>,
unknown,
Label,
'deleteOne' | 'deleteMany'
>,
_criteria: TFilterQuery<Label>,
): Promise<void> {
const labels = await this.find(
typeof _criteria === 'string' ? { _id: _criteria } : _criteria,
);
this.eventEmitter.emit('hook:chatbot:label:delete', labels);
}
/**
* Fetches all label documents and populates the `users` field which references the subscribers.
*
* @returns A promise that resolves with an array of fully populated `LabelFull` documents.
*/
async findAllAndPopulate() {
const query = this.findAllQuery().populate(['users']);
return await this.execute(query, LabelFull);
}
/**
* Fetches a paginated list of label documents based on filters and populates the `users` (subscribers) field.
*
* @param filters - The filter criteria for querying the labels.
* @param pageQuery - The pagination query options.
*
* @returns A promise that resolves with a paginated array of fully populated `LabelFull` documents.
*/
async findPageAndPopulate(
filters: TFilterQuery<Label>,
pageQuery: PageQueryDto<Label>,
) {
const query = this.findPageQuery(filters, pageQuery).populate(['users']);
return await this.execute(query, LabelFull);
}
/**
* Fetches a single label document by its ID and populates the `users` (subscribers) field.
*
* @param id - The ID of the label to be fetched.
*
* @returns A promise that resolves with a fully populated label.
*/
async findOneAndPopulate(id: string) {
const query = this.findOneQuery(id).populate(['users']);
return await this.executeOne(query, LabelFull);
}
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import { UserRepository } from '@/user/repositories/user.repository';
import { UserModel } from '@/user/schemas/user.schema';
import {
installMessageFixtures,
messageFixtures,
} from '@/utils/test/fixtures/message';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { MessageRepository } from './message.repository';
import { SubscriberRepository } from './subscriber.repository';
import { MessageModel, Message } from '../schemas/message.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { AnyMessage } from '../schemas/types/message';
describe('MessageRepository', () => {
let messageRepository: MessageRepository;
let userRepository: UserRepository;
let subscriberRepository: SubscriberRepository;
let messageModel: Model<Message>;
beforeAll(async () => {
const testModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installMessageFixtures),
MongooseModule.forFeature([MessageModel, SubscriberModel, UserModel]),
],
providers: [
MessageRepository,
SubscriberRepository,
UserRepository,
EventEmitter2,
],
}).compile();
messageRepository = testModule.get<MessageRepository>(MessageRepository);
userRepository = testModule.get<UserRepository>(UserRepository);
subscriberRepository =
testModule.get<SubscriberRepository>(SubscriberRepository);
messageModel = testModule.get<Model<Message>>(getModelToken('Message'));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one message by id, and populate its sender and recipient', async () => {
jest.spyOn(messageModel, 'findById');
const message = await messageRepository.findOne({ mid: 'mid-1' });
const sender = await subscriberRepository.findOne(message['sender']);
const recipient = await subscriberRepository.findOne(
message['recipient'],
);
const user = await userRepository.findOne(message['sentBy']);
const result = await messageRepository.findOneAndPopulate(message.id);
expect(messageModel.findById).toHaveBeenCalledWith(message.id);
expect(result).toEqualPayload({
...messageFixtures.find(({ mid }) => mid === message.mid),
sender,
recipient,
sentBy: user.id,
});
});
});
describe('findPageAndPopulate', () => {
it('should find one messages, and foreach message populate its sender and recipient', async () => {
jest.spyOn(messageModel, 'find');
const pageQuery = getPageQuery<AnyMessage>();
const result = await messageRepository.findPageAndPopulate({}, pageQuery);
const allSubscribers = await subscriberRepository.findAll();
const allUsers = await userRepository.findAll();
const allMessages = await messageRepository.findAll();
const messages = allMessages.map((message) => ({
...message,
sender: allSubscribers.find(({ id }) => id === message['sender']),
recipient: allSubscribers.find(({ id }) => id === message['recipient']),
sentBy: allUsers.find(({ id }) => id === message['sentBy']).id,
}));
expect(messageModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(messages);
});
});
});

View File

@@ -0,0 +1,167 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { TFilterQuery, Model, Query } from 'mongoose';
import { LoggerService } from '@/logger/logger.service';
import { NlpSampleCreateDto } from '@/nlp/dto/nlp-sample.dto';
import { NlpSampleState } from '@/nlp/schemas/types';
import { NlpSampleService } from '@/nlp/services/nlp-sample.service';
import { BaseRepository } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { Message, MessageFull } from '../schemas/message.schema';
import { Subscriber } from '../schemas/subscriber.schema';
import { AnyMessage } from '../schemas/types/message';
@Injectable()
export class MessageRepository extends BaseRepository<
AnyMessage,
'sender' | 'recipient'
> {
private readonly nlpSampleService: NlpSampleService;
private readonly logger: LoggerService;
constructor(
@InjectModel(Message.name) readonly model: Model<AnyMessage>,
@Optional() nlpSampleService?: NlpSampleService,
@Optional() logger?: LoggerService,
) {
super(model, Message as new () => AnyMessage);
this.logger = logger;
this.nlpSampleService = nlpSampleService;
}
/**
* Pre-create hook to validate message data before saving.
* If the message is from a end-user (i.e., has a sender), it is saved
* as an inbox NLP sample. Throws an error if neither sender nor recipient
* is provided.
*
* @param _doc - The message document to be created.
*/
async preCreate(_doc: AnyMessage): Promise<void> {
if (_doc) {
if (!('sender' in _doc) && !('recipient' in _doc)) {
this.logger.error('Either sender or recipient must be provided!', _doc);
throw new Error('Either sender or recipient must be provided!');
}
// If message is sent by the user then add it as an inbox sample
if (
'sender' in _doc &&
_doc.sender &&
'message' in _doc &&
'text' in _doc.message
) {
const record: NlpSampleCreateDto = {
text: _doc.message.text,
type: NlpSampleState.inbox,
trained: false,
};
try {
await this.nlpSampleService.findOneOrCreate(record, record);
this.logger.debug('User message saved as a inbox sample !');
} catch (err) {
this.logger.error(
'Unable to add message as a new inbox sample!',
err,
);
throw err;
}
}
}
}
/**
* Retrieves a paginated list of messages with sender and recipient populated.
* Uses filter criteria and pagination settings for the query.
*
* @param filters - Filter criteria for querying messages.
* @param pageQuery - Pagination settings, including skip, limit, and sort order.
*
* @returns A paginated list of messages with sender and recipient details populated.
*/
async findPageAndPopulate(
filters: TFilterQuery<AnyMessage>,
pageQuery: PageQueryDto<AnyMessage>,
) {
const query = this.findPageQuery(filters, pageQuery).populate([
'sender',
'recipient',
]);
return await this.execute(
query as Query<AnyMessage[], AnyMessage, object, AnyMessage, 'find'>,
MessageFull,
);
}
/**
* Retrieves a single message by its ID, populating the sender and recipient fields.
*
* @param id - The ID of the message to retrieve.
*
* @returns The message with sender and recipient details populated.
*/
async findOneAndPopulate(id: string) {
const query = this.findOneQuery(id).populate(['sender', 'recipient']);
return await this.executeOne(query, MessageFull);
}
/**
* Retrieves the message history for a given subscriber, with messages sent or received
* before the specified date. Results are limited and sorted by creation date.
*
* @param subscriber - The subscriber whose message history is being retrieved.
* @param until - Optional date to retrieve messages sent before (default: current date).
* @param limit - Optional limit on the number of messages to retrieve (default: 30).
*
* @returns The message history until the specified date.
*/
async findHistoryUntilDate(
subscriber: Subscriber,
until = new Date(),
limit: number = 30,
) {
return await this.findPage(
{
$or: [{ recipient: subscriber.id }, { sender: subscriber.id }],
createdAt: { $lt: until },
},
{ skip: 0, limit, sort: ['createdAt', 'desc'] },
);
}
/**
* Retrieves the message history for a given subscriber, with messages sent or received
* after the specified date. Results are limited and sorted by creation date.
*
* @param subscriber The subscriber whose message history is being retrieved.
* @param since Optional date to retrieve messages sent after (default: current date).
* @param limit Optional limit on the number of messages to retrieve (default: 30).
*
* @returns The message history since the specified date.
*/
async findHistorySinceDate(
subscriber: Subscriber,
since = new Date(),
limit: number = 30,
) {
return await this.findPage(
{
$or: [{ recipient: subscriber.id }, { sender: subscriber.id }],
createdAt: { $gt: since },
},
{ skip: 0, limit, sort: ['createdAt', 'asc'] },
);
}
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { Model } from 'mongoose';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import {
Attachment,
AttachmentModel,
} from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { UserRepository } from '@/user/repositories/user.repository';
import { UserModel, User } from '@/user/schemas/user.schema';
import {
installSubscriberFixtures,
subscriberFixtures,
} from '@/utils/test/fixtures/subscriber';
import { getPageQuery } from '@/utils/test/pagination';
import { sortRowsBy } from '@/utils/test/sort';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LabelRepository } from './label.repository';
import { SubscriberRepository } from './subscriber.repository';
import { LabelModel, Label } from '../schemas/label.schema';
import {
SubscriberModel,
Subscriber,
SubscriberFull,
} from '../schemas/subscriber.schema';
describe('SubscriberRepository', () => {
let subscriberRepository: SubscriberRepository;
let subscriberModel: Model<Subscriber>;
let labelRepository: LabelRepository;
let userRepository: UserRepository;
let attachmentRepository: AttachmentRepository;
let allLabels: Label[];
let allUsers: User[];
let allSubscribers: Subscriber[];
let allAttachments: Attachment[];
let subscribersWithPopulatedFields: SubscriberFull[];
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installSubscriberFixtures),
MongooseModule.forFeature([
SubscriberModel,
LabelModel,
UserModel,
AttachmentModel,
]),
],
providers: [
SubscriberRepository,
LabelRepository,
UserRepository,
EventEmitter2,
LoggerService,
AttachmentService,
AttachmentRepository,
],
}).compile();
subscriberRepository =
module.get<SubscriberRepository>(SubscriberRepository);
labelRepository = module.get<LabelRepository>(LabelRepository);
userRepository = module.get<UserRepository>(UserRepository);
attachmentRepository =
module.get<AttachmentRepository>(AttachmentRepository);
subscriberModel = module.get<Model<Subscriber>>(
getModelToken('Subscriber'),
);
allLabels = await labelRepository.findAll();
allSubscribers = await subscriberRepository.findAll();
allUsers = await userRepository.findAll();
allAttachments = await attachmentRepository.findAll();
subscribersWithPopulatedFields = allSubscribers.map((subscriber) => ({
...subscriber,
labels: allLabels.filter((label) => subscriber.labels.includes(label.id)),
assignedTo:
allUsers.find(({ id }) => subscriber.assignedTo === id) || null,
avatar: allAttachments.find(({ id }) => subscriber.avatar === id) || null,
}));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one subscriber by id,and populate its labels', async () => {
jest.spyOn(subscriberModel, 'findById');
const subscriber = await subscriberRepository.findOne({
first_name: 'Jhon',
});
const allLabels = await labelRepository.findAll();
const result = await subscriberRepository.findOneAndPopulate(
subscriber.id,
);
const subscriberWithLabels = {
...subscriberFixtures.find(
({ first_name }) => first_name === subscriber.first_name,
),
labels: allLabels.filter((label) =>
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
};
expect(subscriberModel.findById).toHaveBeenCalledWith(subscriber.id);
expect(result).toEqualPayload(subscriberWithLabels);
});
});
describe('findPageAndPopulate', () => {
const pageQuery = getPageQuery<Subscriber>();
it('should find subscribers, and foreach subscriber populate the corresponding labels', async () => {
jest.spyOn(subscriberModel, 'find');
const result = await subscriberRepository.findPageAndPopulate(
{},
pageQuery,
);
expect(subscriberModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(
subscribersWithPopulatedFields.sort(sortRowsBy),
);
});
});
describe('findAllAndPopulate', () => {
it('should return all subscribers, and foreach subscriber populate the corresponding labels', async () => {
jest.spyOn(subscriberModel, 'find');
const result = await subscriberRepository.findAllAndPopulate();
expect(subscriberModel.find).toHaveBeenCalledWith({});
expect(result).toEqualPayload(
subscribersWithPopulatedFields.sort(sortRowsBy),
);
});
});
});

View File

@@ -0,0 +1,278 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import {
Document,
Model,
Query,
TFilterQuery,
UpdateQuery,
UpdateWithAggregationPipeline,
} from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { SubscriberUpdateDto } from '../dto/subscriber.dto';
import {
Subscriber,
SubscriberDocument,
SubscriberFull,
} from '../schemas/subscriber.schema';
@Injectable()
export class SubscriberRepository extends BaseRepository<
Subscriber,
'labels' | 'assignedTo' | 'avatar'
> {
constructor(
@InjectModel(Subscriber.name) readonly model: Model<Subscriber>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Subscriber);
}
/**
* Emits events related to the creation of a new subscriber.
*
* @param _created - The newly created subscriber document.
*/
async postCreate(_created: SubscriberDocument): Promise<void> {
this.eventEmitter.emit(
'hook:stats:entry',
'new_users',
'New users',
_created,
);
this.eventEmitter.emit('hook:chatbot:subscriber:create', _created);
}
/**
* Emits events before updating a subscriber. Specifically handles the
* assignment of the subscriber and triggers appropriate events.
*
* @param _query - The Mongoose query object for finding and updating a subscriber.
* @param criteria - The filter criteria used to find the subscriber.
* @param updates - The update data, which may include fields like `assignedTo`.
*/
async preUpdate(
_query: Query<
Document<Subscriber, any, any>,
Document<Subscriber, any, any>,
unknown,
Subscriber,
'findOneAndUpdate'
>,
criteria: TFilterQuery<Subscriber>,
updates:
| UpdateWithAggregationPipeline
| UpdateQuery<Document<Subscriber, any, any>>,
): Promise<void> {
const subscriberUpdates: SubscriberUpdateDto = updates?.['$set'];
this.eventEmitter.emit(
'hook:chatbot:subscriber:update:before',
criteria,
subscriberUpdates,
);
const oldSubscriber = await this.findOne(criteria);
if (subscriberUpdates.assignedTo !== oldSubscriber?.assignedTo) {
this.eventEmitter.emit(
'hook:subscriber:assign',
subscriberUpdates,
oldSubscriber,
);
if (!(subscriberUpdates.assignedTo && oldSubscriber?.assignedTo)) {
this.eventEmitter.emit(
'hook:analytics:passation',
oldSubscriber,
!!subscriberUpdates?.assignedTo,
);
}
}
}
/**
* Emits an event after successfully updating a subscriber.
* Triggers the event with the updated subscriber data.
*
* @param _query - The Mongoose query object for finding and updating a subscriber.
* @param updated - The updated subscriber entity.
*/
async postUpdate(
_query: Query<
Document<Subscriber, any, any>,
Document<Subscriber, any, any>,
unknown,
Subscriber,
'findOneAndUpdate'
>,
updated: Subscriber,
) {
this.eventEmitter.emit('hook:chatbot:subscriber:update:after', updated);
}
/**
* Constructs a query to find a subscriber by their foreign ID.
*
* @param id - The foreign ID of the subscriber.
*
* @returns The constructed query object.
*/
findByForeignIdQuery(id: string) {
return this.findPageQuery(
{ foreign_id: id },
{ skip: 0, limit: 1, sort: ['lastvisit', 'desc'] },
);
}
/**
* Finds a single subscriber by his foreign ID (channel's id).
*
* @param id - The foreign ID of the subscriber.
*
* @returns The found subscriber entity.
*/
async findOneByForeignId(id: string): Promise<Subscriber> {
const query = this.findByForeignIdQuery(id);
const [result] = await this.execute(query, Subscriber);
return result;
}
/**
* Finds a subscriber by their foreign ID and populates related fields such as `labels` and `assignedTo`.
*
* @param id - The foreign ID of the subscriber.
*
* @returns The found subscriber entity with populated fields.
*/
async findOneByForeignIdAndPopulate(id: string): Promise<SubscriberFull> {
const query = this.findByForeignIdQuery(id).populate([
'labels',
'assignedTo',
]);
const [result] = await this.execute(query, SubscriberFull);
return result;
}
/**
* Updates a subscriber's information based on their foreign ID.
*
* @param id - The foreign ID of the subscriber.
* @param updates - The update data to apply to the subscriber.
*
* @returns The updated subscriber entity.
*/
async updateOneByForeignIdQuery(
id: string,
updates: SubscriberUpdateDto,
): Promise<Subscriber> {
return await this.updateOne({ foreign_id: id }, updates);
}
/**
* Unassigns a subscriber by their foreign ID by setting the `assignedTo` field to `null`.
*
* @param foreignId - The foreign ID of the subscriber.
*
* @returns The updated subscriber entity.
*/
async handBackByForeignIdQuery(foreignId: string): Promise<Subscriber> {
return await this.updateOne(
{
foreign_id: foreignId,
assignedTo: { $ne: null },
},
{
assignedTo: null,
},
);
}
/**
* Assigns a subscriber to a new user by their foreign ID.
*
* @param foreignId The foreign ID of the subscriber.
* @param userId The ID of the user to assign the subscriber to.
*
* @returns The updated subscriber entity.
*/
async handOverByForeignIdQuery(
foreignId: string,
userId: string,
): Promise<Subscriber> {
return await this.updateOne(
{
foreign_id: foreignId,
assignedTo: { $ne: userId },
},
{
assignedTo: userId,
},
);
}
/**
* Finds all subscribers and populates related fields such as `labels`, `assignedTo`, and `avatar`.
*
* @returns A list of all subscribers with populated fields.
*/
async findAllAndPopulate(): Promise<SubscriberFull[]> {
const query = this.findAllQuery().populate([
'labels',
'assignedTo',
'avatar',
]);
return await this.execute(query, SubscriberFull);
}
/**
* Finds subscribers using pagination and populates related fields such as `labels`, `assignedTo`, and `avatar`.
*
* @param filters - The filter criteria to apply when finding subscribers.
* @param pageQuery - The pagination query.
*
* @returns A paginated list of subscribers with populated fields.
*/
async findPageAndPopulate(
filters: TFilterQuery<Subscriber>,
pageQuery: PageQueryDto<Subscriber>,
): Promise<SubscriberFull[]> {
const query = this.findPageQuery(filters, pageQuery).populate([
'labels',
'assignedTo',
'avatar',
]);
return await this.execute(query, SubscriberFull);
}
/**
* Finds a single subscriber by criteria and populates related fields such as `labels`, `assignedTo`, and `avatar`.
*
* @param criteria - The filter criteria to apply when finding a subscriber.
*
* @returns The found subscriber entity with populated fields.
*/
async findOneAndPopulate(
criteria: string | TFilterQuery<Subscriber>,
): Promise<SubscriberFull> {
const query = this.findOneQuery(criteria).populate([
'labels',
'assignedTo',
'avatar',
]);
return await this.executeOne(query, SubscriberFull);
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query, Types } from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { Translation } from '../schemas/translation.schema';
@Injectable()
export class TranslationRepository extends BaseRepository<Translation> {
constructor(
@InjectModel(Translation.name) readonly model: Model<Translation>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Translation);
}
/**
* Emits an event after a translation document is updated.
*
* @param query - The query object representing the update operation.
* @param updated - The updated translation document.
*/
async postUpdate(
_query: Query<
Document<Translation, any, any>,
Document<Translation, any, any>,
unknown,
Translation,
'findOneAndUpdate'
>,
_updated: Translation,
) {
this.eventEmitter.emit('hook:translation:update');
}
/**
* Emits an event after a new translation document is created.
*
* @param created - The newly created translation document.
*/
async postCreate(
_created: Document<unknown, unknown, Translation> &
Translation & { _id: Types.ObjectId },
) {
this.eventEmitter.emit('hook:translation:create');
}
/**
* Emits an event after a translation document is deleted.
*
* @param query - The query object representing the delete operation.
* @param result - The result of the delete operation.
*/
async postDelete(
_query: Query<
DeleteResult,
Document<Translation, any, any>,
unknown,
Translation,
'deleteOne' | 'deleteMany'
>,
_result: DeleteResult,
) {
this.eventEmitter.emit('hook:translation:delete');
}
}

View File

@@ -0,0 +1,196 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Exclude, Transform, Type } from 'class-transformer';
import { Schema as MongooseSchema, THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Category } from './category.schema';
import { Label } from './label.schema';
import { CaptureVar } from './types/capture-var';
import { BlockMessage } from './types/message';
import { BlockOptions } from './types/options';
import { Pattern } from './types/pattern';
import { Position } from './types/position';
import { isValidMessage } from '../validation-rules/is-message';
import { isPatternList } from '../validation-rules/is-pattern-list';
import { isPosition } from '../validation-rules/is-position';
import { isValidVarCapture } from '../validation-rules/is-valid-capture';
@Schema({ timestamps: true })
export class BlockStub extends BaseSchema {
@Prop({
type: String,
required: true,
})
name: string;
@Prop({
type: Object,
validate: isPatternList,
default: [],
})
patterns?: Pattern[];
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Label',
default: [],
},
])
trigger_labels?: unknown;
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Label',
default: [],
},
])
assign_labels?: unknown;
@Prop({
type: Object,
default: [],
})
trigger_channels?: string[];
@Prop({
type: Object,
default: {},
})
options?: BlockOptions;
@Prop({
type: Object,
validate: isValidMessage,
})
message: BlockMessage;
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
default: [],
},
])
nextBlocks?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
})
attachedBlock?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Category',
})
category: unknown;
@Prop({
type: Boolean,
default: false,
})
starts_conversation?: boolean;
@Prop({
type: Object,
validate: isValidVarCapture,
default: [],
})
capture_vars?: CaptureVar[];
@Prop({
type: Object,
validate: isPosition,
})
position: Position;
@Prop({
type: Boolean,
default: false,
})
builtin?: boolean;
}
@Schema({ timestamps: true })
export class Block extends BlockStub {
@Transform(({ obj }) => obj.trigger_labels?.map((elem) => elem.toString()))
trigger_labels?: string[];
@Transform(({ obj }) => obj.assign_labels?.map((elem) => elem.toString()))
assign_labels?: string[];
@Transform(({ obj }) => obj.nextBlocks?.map((elem) => elem.toString()))
nextBlocks?: string[];
@Transform(({ obj }) => obj.attachedBlock?.toString() || null)
attachedBlock?: string;
@Transform(({ obj }) => obj.category.toString())
category: string;
@Exclude()
previousBlocks?: never;
@Exclude()
attachedToBlock?: never | null;
}
@Schema({ timestamps: true })
export class BlockFull extends BlockStub {
@Type(() => Label)
trigger_labels: Label[];
@Type(() => Label)
assign_labels: Label[];
@Type(() => Block)
nextBlocks?: Block[];
@Type(() => Block)
attachedBlock?: Block;
@Type(() => Category)
category: Category;
@Type(() => Block)
previousBlocks: Block[];
@Type(() => Block)
attachedToBlock?: Block;
}
export type BlockDocument = THydratedDocument<Block>;
export const BlockModel: ModelDefinition = LifecycleHookManager.attach({
name: Block.name,
schema: SchemaFactory.createForClass(BlockStub),
});
BlockModel.schema.virtual('previousBlocks', {
ref: 'Block',
localField: '_id',
foreignField: 'nextBlocks',
justOne: false,
});
BlockModel.schema.virtual('attachedToBlock', {
ref: 'Block',
localField: '_id',
foreignField: 'attachedBlock',
justOne: true,
});
export default BlockModel.schema;

View File

@@ -0,0 +1,51 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@Schema({ timestamps: true })
export class Category extends BaseSchema {
@Prop({
type: String,
unique: true,
required: true,
})
label: string;
@Prop({
type: Boolean,
default: false,
})
builtin?: boolean;
@Prop({
type: Number,
default: 100,
})
zoom?: number;
@Prop({
type: [Number, Number],
default: [0, 0],
})
offset?: [number, number];
}
export const CategoryModel: ModelDefinition = LifecycleHookManager.attach({
name: Category.name,
schema: SchemaFactory.createForClass(Category),
});
export type CategoryDocument = THydratedDocument<Category>;
export default CategoryModel.schema;

View File

@@ -0,0 +1,40 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
@Schema({ timestamps: true })
export class ContextVar extends BaseSchema {
@Prop({
type: String,
unique: true,
required: true,
})
label: string;
@Prop({
type: String,
unique: true,
required: true,
match: /^[a-z_0-9]+$/,
})
name: string;
}
export const ContextVarModel: ModelDefinition = {
name: ContextVar.name,
schema: SchemaFactory.createForClass(ContextVar),
};
export type ContextVarDocument = THydratedDocument<ContextVar>;
export default ContextVarModel.schema;

View File

@@ -0,0 +1,105 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { Transform, Type } from 'class-transformer';
import { THydratedDocument, Schema as MongooseSchema } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { Block } from './block.schema';
import { Subscriber } from './subscriber.schema';
import { Context } from './types/context';
export function getDefaultConversationContext(): Context {
return {
vars: {}, // Used for capturing vars from user entries
user: {
first_name: '',
last_name: '',
} as Subscriber,
user_location: {
// Used for capturing geolocation from QR
lat: 0.0,
lon: 0.0,
},
skip: {}, // Used for list pagination
attempt: 0, // Used to track fallback max attempts
};
}
@Schema({ timestamps: true })
class ConversationStub extends BaseSchema {
@Prop({
type: MongooseSchema.Types.ObjectId,
required: true,
ref: 'Subscriber',
})
sender: unknown;
@Prop({
type: Boolean,
default: true,
})
active?: boolean;
@Prop({
type: Object,
default: getDefaultConversationContext(),
})
context?: Context;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
})
current?: unknown;
@Prop([
{
type: MongooseSchema.Types.ObjectId,
ref: 'Block',
default: [],
},
])
next?: unknown;
}
@Schema({ timestamps: true })
export class Conversation extends ConversationStub {
@Transform(({ obj }) => obj.sender.toString())
sender: string;
@Transform(({ obj }) => obj.current.toString())
current?: string;
@Transform(({ obj }) => obj.next.map((elem) => elem.toString()))
next?: string[];
}
@Schema({ timestamps: true })
export class ConversationFull extends ConversationStub {
@Type(() => Subscriber)
sender: Subscriber;
@Type(() => Block)
current: Block;
@Type(() => Block)
next: Block[];
}
export type ConversationDocument = THydratedDocument<Conversation>;
export const ConversationModel: ModelDefinition = {
name: Conversation.name,
schema: SchemaFactory.createForClass(ConversationStub),
};
export default ConversationModel.schema;

View File

@@ -0,0 +1,79 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { Exclude, Type } from 'class-transformer';
import { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Subscriber } from './subscriber.schema';
@Schema({ timestamps: true })
export class LabelStub extends BaseSchema {
@Prop({
type: String,
unique: true,
required: true,
})
title: string;
@Prop({
type: String,
unique: true,
required: true,
match: /^[A-Z_0-9]+$/,
})
name: string;
@Prop({
type: Object,
})
label_id?: Record<string, any>; // Indexed by channel name
@Prop({
type: String,
})
description?: string;
@Prop({
type: Boolean,
default: false,
})
builtin?: boolean;
}
@Schema({ timestamps: true })
export class Label extends LabelStub {
@Exclude()
users?: never;
}
@Schema({ timestamps: true })
export class LabelFull extends LabelStub {
@Type(() => Subscriber)
users?: Subscriber[];
}
export type LabelDocument = THydratedDocument<Label>;
export const LabelModel: ModelDefinition = LifecycleHookManager.attach({
name: Label.name,
schema: SchemaFactory.createForClass(LabelStub),
});
LabelModel.schema.virtual('users', {
ref: 'Subscriber',
localField: '_id',
foreignField: 'labels',
justOne: false,
});
export default LabelModel.schema;

View File

@@ -0,0 +1,104 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { Transform, Type } from 'class-transformer';
import { Schema as MongooseSchema } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Subscriber } from './subscriber.schema';
import { StdIncomingMessage, StdOutgoingMessage } from './types/message';
@Schema({ timestamps: true })
export class MessageStub extends BaseSchema {
@Prop({
type: String,
required: false,
//TODO : add default value for mid
})
mid?: string;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'Subscriber',
})
sender?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'Subscriber',
})
recipient?: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'User',
})
sentBy?: unknown;
@Prop({
type: Object,
required: true,
})
message: StdOutgoingMessage | StdIncomingMessage;
@Prop({
type: Boolean,
default: false,
})
read?: boolean;
@Prop({
type: Boolean,
default: false,
})
delivery?: boolean;
@Prop({
type: Boolean,
default: false,
})
handover?: boolean;
}
@Schema({ timestamps: true })
export class Message extends MessageStub {
@Transform(({ obj }) => obj.sender?.toString())
sender?: string;
@Transform(({ obj }) => obj.recipient?.toString())
recipient?: string;
@Transform(({ obj }) => obj.sentBy?.toString())
sentBy?: string;
}
@Schema({ timestamps: true })
export class MessageFull extends MessageStub {
@Type(() => Subscriber)
sender?: Subscriber;
@Type(() => Subscriber)
recipient?: Subscriber;
@Transform(({ obj }) => obj.sentBy?.toString())
sentBy?: string; // sendBy is never populate
}
export const MessageModel: ModelDefinition = LifecycleHookManager.attach({
name: Message.name,
schema: SchemaFactory.createForClass(MessageStub),
});
export default MessageModel.schema;

View File

@@ -0,0 +1,142 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { Transform, Type } from 'class-transformer';
import { THydratedDocument, Schema as MongooseSchema } from 'mongoose';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { User } from '@/user/schemas/user.schema';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { Label } from './label.schema';
import { ChannelData } from './types/channel';
@Schema({ timestamps: true })
export class SubscriberStub extends BaseSchema {
@Prop({
type: String,
required: true,
})
first_name: string;
@Prop({
type: String,
required: true,
})
last_name: string;
@Prop({
type: String,
})
locale: string;
@Prop({
type: Number,
default: 0,
})
timezone?: number;
@Prop({
type: String,
})
language: string;
@Prop({
type: String,
})
gender: string;
@Prop({
type: String,
})
country: string;
@Prop({
type: String,
})
foreign_id: string;
@Prop([
{ type: MongooseSchema.Types.ObjectId, required: false, ref: 'Label' },
])
labels: unknown;
@Prop({
type: MongooseSchema.Types.ObjectId,
required: false,
ref: 'User',
default: null,
})
assignedTo?: unknown;
@Prop({
type: Date,
default: null,
})
assignedAt?: Date;
@Prop({
type: Date,
default: () => Date.now() + 7 * 24 * 60 * 60 * 1000,
})
lastvisit?: Date;
@Prop({
type: Date,
default: () => Date.now() + 7 * 24 * 60 * 60 * 1000,
})
retainedFrom?: Date;
@Prop({
type: Object,
})
channel: ChannelData;
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Attachment',
default: null,
})
avatar?: unknown;
}
@Schema({ timestamps: true })
export class Subscriber extends SubscriberStub {
@Transform(({ obj }) => obj.labels.map((label) => label.toString()))
labels: string[];
@Transform(({ obj }) => (obj.assignedTo ? obj.assignedTo.toString() : null))
assignedTo?: string;
@Transform(({ obj }) => obj.avatar?.toString() || null)
avatar?: string;
}
@Schema({ timestamps: true })
export class SubscriberFull extends SubscriberStub {
@Type(() => Label)
labels: Label[];
@Type(() => User)
assignedTo?: User | null;
@Type(() => Attachment)
avatar: Attachment | null;
}
export type SubscriberDocument = THydratedDocument<Subscriber>;
export const SubscriberModel: ModelDefinition = LifecycleHookManager.attach({
name: Subscriber.name,
schema: SchemaFactory.createForClass(SubscriberStub),
});
export default SubscriberModel.schema;

View File

@@ -0,0 +1,43 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose';
import { THydratedDocument } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
@Schema({ timestamps: true })
export class Translation extends BaseSchema {
@Prop({
type: String,
required: true,
unique: true,
})
str: string;
@Prop({
type: Object,
required: true,
})
translations: Record<string, string>;
@Prop({
type: Number,
})
translated: number;
}
export const TranslationModel: ModelDefinition = {
name: Translation.name,
schema: SchemaFactory.createForClass(Translation),
};
export type TranslationDocument = THydratedDocument<Translation>;
export default TranslationModel.schema;

View File

@@ -0,0 +1,39 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Attachment } from '@/attachment/schemas/attachment.schema';
export enum FileType {
image = 'image',
video = 'video',
audio = 'audio',
file = 'file',
unknown = 'unknown',
}
export type AttachmentForeignKey = {
url?: string;
attachment_id: string;
};
export type WithUrl<A> = A & { url?: string };
export interface AttachmentPayload<
A extends WithUrl<Attachment> | AttachmentForeignKey,
> {
type: FileType;
payload: A;
}
export interface IncomingAttachmentPayload {
type: FileType;
payload: {
url: string;
};
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
export enum ButtonType {
postback = 'postback',
web_url = 'web_url',
}
export type PostBackButton = {
type: ButtonType.postback;
title: string;
payload: string;
};
export type WebUrlButton = {
type: ButtonType.web_url;
title: string;
url: string;
messenger_extensions?: boolean;
webview_height_ratio?: 'compact' | 'tall' | 'full';
};
export type Button = PostBackButton | WebUrlButton;

View File

@@ -0,0 +1,16 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
export interface CaptureVar {
// entity=`-1` to match text message
// entity=`-2` for postback payload
// entity is `String` for NLP entities
entity: number | string;
context_var: string;
}

Some files were not shown because too many files have changed in this diff Show More