Merge branch 'main' into 48-request-context-vars-permanent-option

This commit is contained in:
medtaher
2024-09-28 15:01:29 +01:00
131 changed files with 3087 additions and 989 deletions

View File

@@ -77,6 +77,7 @@ jobs:
context: ./widget/
file: ./widget/Dockerfile
platforms: linux/amd64,linux/arm64
target: production
push: true
tags: hexastack/hexabot-widget:latest

View File

@@ -1,64 +1,60 @@
COMPOSE_FILES := -f ./docker/docker-compose.yml
# Makefile
FOLDER := ./docker
# Function to add service files
define add_service
ifeq ($(PROD), true)
COMPOSE_FILES += -f ./docker/docker-compose.$(1).yml
ifneq ($(wildcard ./docker/docker-compose.$(1).prod.yml),)
COMPOSE_FILES += -f ./docker/docker-compose.$(1).prod.yml
endif
else ifeq ($(DEV_MODE), true)
COMPOSE_FILES += -f ./docker/docker-compose.$(1).yml
ifneq ($(wildcard ./docker/docker-compose.$(1).dev.yml),)
COMPOSE_FILES += -f ./docker/docker-compose.$(1).dev.yml
endif
endif
# The services that can be toggled
SERVICES := nginx nlu smtp4dev
# Function to dynamically add Docker Compose files based on enabled services
define compose_files
$(foreach service,$(SERVICES),$(if $($(shell echo $(service) | tr a-z A-Z)), -f $(FOLDER)/docker-compose.$(service).yml))
endef
# Function to dynamically add Docker Compose dev files based on enabled services and file existence
define compose_dev_files
$(foreach service,$(SERVICES), \
$(if $($(shell echo $(service) | tr a-z A-Z)), \
$(if $(shell [ -f $(FOLDER)/docker-compose.$(service).dev.yml ] && echo yes), -f $(FOLDER)/docker-compose.$(service).dev.yml)))
endef
# Function to set up COMPOSE_FILES
define compose_files
ifeq ($(1), true)
ifneq ($(wildcard ./docker/docker-compose.dev.yml),)
COMPOSE_FILES += -f ./docker/docker-compose.dev.yml
endif
endif
ifneq ($(NGINX),)
$(eval $(call add_service,nginx))
endif
ifneq ($(NLU),)
$(eval $(call add_service,nlu))
endif
# Function to dynamically add Docker Compose dev files based on enabled services and file existence
define compose_prod_files
$(foreach service,$(SERVICES), \
$(if $($(shell echo $(service) | tr a-z A-Z)), \
$(if $(shell [ -f $(FOLDER)/docker-compose.$(service).prod.yml ] && echo yes), -f $(FOLDER)/docker-compose.$(service).dev.yml)))
endef
# Ensure .env file exists and matches .env.example
check-env:
@if [ ! -f "./docker/.env" ]; then \
@if [ ! -f "$(FOLDER)/.env" ]; then \
echo "Error: .env file does not exist. Creating one now from .env.example ..."; \
cp ./docker/.env.example ./docker/.env; \
cp $(FOLDER)/.env.example $(FOLDER)/.env; \
fi
@echo "Checking .env file for missing variables..."
@awk -F '=' 'NR==FNR {a[$$1]; next} !($$1 in a) {print "Missing env var: " $$1}' ./docker/.env ./docker/.env.example
@awk -F '=' 'NR==FNR {a[$$1]; next} !($$1 in a) {print "Missing env var: " $$1}' $(FOLDER)/.env $(FOLDER)/.env.example
init:
cp ./docker/.env.example ./docker/.env
dev: check-env
$(eval $(call compose_files,true))
docker compose $(COMPOSE_FILES) up -d
cp $(FOLDER)/.env.example $(FOLDER)/.env
# Start command: runs docker-compose with the main file and any additional service files
start: check-env
$(eval $(call compose_files,false))
docker compose $(COMPOSE_FILES) up -d
@docker compose -f $(FOLDER)/docker-compose.yml $(call compose_files) up -d
stop: check-env
$(eval $(call compose_files,true))
docker compose $(COMPOSE_FILES) down
# Dev command: runs docker-compose with the main file, dev file, and any additional service dev files (if they exist)
dev: check-env
@docker compose -f $(FOLDER)/docker-compose.yml -f $(FOLDER)/docker-compose.dev.yml $(call compose_files) $(call compose_dev_files) up -d
destroy: check-env
$(eval $(call compose_files,true))
docker compose $(COMPOSE_FILES) down -v
# Start command: runs docker-compose with the main file and any additional service files
start-prod: check-env
@docker compose -f $(FOLDER)/docker-compose.yml -f $(FOLDER)/docker-compose.prod.yml $(call compose_files) $(call compose_prod_files) up -d
# Stop command: stops the running containers
stop:
@docker compose -f $(FOLDER)/docker-compose.yml -f $(FOLDER)/docker-compose.dev.yml $(call compose_files) $(call compose_dev_files) $(call compose_prod_files) down
# Destroy command: stops the running containers and removes the volumes
destroy:
@docker compose -f $(FOLDER)/docker-compose.yml -f $(FOLDER)/docker-compose.dev.yml $(call compose_files) $(call compose_dev_files) $(call compose_prod_files) down -v
# Migrate command:
migrate-up:
$(eval $(call compose_files,false))
docker-compose $(COMPOSE_FILES) up --no-deps -d database-init
@docker compose -f $(FOLDER)/docker-compose.yml -f $(FOLDER)/docker-compose.dev.yml $(call compose_files) $(call compose_dev_files) up --no-deps -d database-init

View File

@@ -11,6 +11,8 @@
import fs from 'fs';
import path from 'path';
import escapeRegExp from 'lodash/escapeRegExp';
// Get the argument passed (e.g., "all-users-fr")
const arg: string | undefined = process.argv[2];
@@ -25,7 +27,7 @@ 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$`);
const regex = new RegExp(`^[0-9]+-${escapeRegExp(arg)}\\.ts$`);
return regex.test(file);
});

View File

@@ -26,13 +26,13 @@ import conversationSchema, {
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 translationSchema, {
Translation,
} from '@/i18n/schemas/translation.schema';
import nlpEntitySchema, { NlpEntity } from '@/nlp/schemas/nlp-entity.schema';
import nlpSampleEntitySchema, {
NlpSampleEntity,

7
api/package-lock.json generated
View File

@@ -67,6 +67,7 @@
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.10",
"@types/jest": "^29.5.2",
"@types/lodash": "^4.17.9",
"@types/minio": "^7.1.1",
"@types/module-alias": "^2.0.4",
"@types/multer": "^1.4.11",
@@ -6027,6 +6028,12 @@
"@types/node": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.9",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz",
"integrity": "sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==",
"dev": true
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",

View File

@@ -91,6 +91,7 @@
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.10",
"@types/jest": "^29.5.2",
"@types/lodash": "^4.17.9",
"@types/module-alias": "^2.0.4",
"@types/multer": "^1.4.11",
"@types/node": "^20.3.1",

View File

@@ -32,7 +32,7 @@ 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 { I18nModule } from './i18n/i18n.module';
import { LoggerModule } from './logger/logger.module';
import { DtoUpdateMiddleware } from './middlewares/dto.update.middleware';
import { NlpModule } from './nlp/nlp.module';
@@ -44,7 +44,7 @@ import idPlugin from './utils/schema-plugin/id.plugin';
import { WebsocketModule } from './websocket/websocket.module';
const i18nOptions: I18nOptions = {
fallbackLanguage: config.chatbot.lang.default,
fallbackLanguage: 'en',
loaderOptions: {
path: path.join(__dirname, '/config/i18n/'),
watch: true,
@@ -120,7 +120,7 @@ const i18nOptions: I18nOptions = {
ignoreErrors: false,
}),
CsrfModule,
ExtendedI18nModule.forRoot(i18nOptions),
I18nModule.forRoot(i18nOptions),
CacheModule.register({
isGlobal: true,
ttl: config.cache.ttl,

View File

@@ -9,11 +9,11 @@
import { Injectable } from '@nestjs/common';
import { ExtendedI18nService } from './extended-i18n.service';
import { I18nService } from './i18n/services/i18n.service';
@Injectable()
export class AppService {
constructor(private readonly i18n: ExtendedI18nService) {}
constructor(private readonly i18n: I18nService) {}
getHello(): string {
return this.i18n.t('welcome', { lang: 'en' });

View File

@@ -13,6 +13,7 @@ import { THydratedDocument } from 'mongoose';
import { FileType } from '@/chat/schemas/types/attachment';
import { config } from '@/config';
import { BaseSchema } from '@/utils/generics/base-schema';
import { buildURL } from '@/utils/helpers/URL';
import { MIME_REGEX } from '../utilities';
@@ -89,7 +90,10 @@ export class Attachment extends BaseSchema {
attachmentId: string,
attachmentName: string = '',
): string {
return `${config.parameters.apiUrl}/attachment/download/${attachmentId}/${attachmentName}`;
return buildURL(
config.parameters.apiUrl,
`/attachment/download/${attachmentId}/${attachmentName}`,
);
}
/**
@@ -119,7 +123,10 @@ export const AttachmentModel: ModelDefinition = {
AttachmentModel.schema.virtual('url').get(function () {
if (this._id && this.name)
return `${config.apiPath}/attachment/download/${this._id}/${this.name}`;
return buildURL(
config.apiPath,
`/attachment/download/${this._id}/${this.name}`,
);
return '';
});

View File

@@ -23,7 +23,6 @@ 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';
@@ -31,7 +30,6 @@ 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';
@@ -39,10 +37,8 @@ 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';
@@ -52,7 +48,6 @@ 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: [
@@ -63,7 +58,6 @@ import { TranslationService } from './services/translation.service';
BlockModel,
MessageModel,
SubscriberModel,
TranslationModel,
ConversationModel,
SubscriberModel,
]),
@@ -81,7 +75,6 @@ import { TranslationService } from './services/translation.service';
BlockController,
MessageController,
SubscriberController,
TranslationController,
],
providers: [
CategoryRepository,
@@ -90,7 +83,6 @@ import { TranslationService } from './services/translation.service';
BlockRepository,
MessageRepository,
SubscriberRepository,
TranslationRepository,
ConversationRepository,
CategoryService,
ContextVarService,
@@ -98,13 +90,11 @@ import { TranslationService } from './services/translation.service';
BlockService,
MessageService,
SubscriberService,
TranslationService,
CategorySeeder,
ContextVarSeeder,
ConversationService,
ChatService,
BotService,
TranslationSeeder,
],
exports: [SubscriberService, MessageService, LabelService, BlockService],
})

View File

@@ -19,7 +19,10 @@ 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 { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
@@ -86,6 +89,7 @@ describe('BlockController', () => {
UserModel,
RoleModel,
PermissionModel,
LanguageModel,
]),
],
providers: [
@@ -97,6 +101,7 @@ describe('BlockController', () => {
UserRepository,
RoleRepository,
PermissionRepository,
LanguageRepository,
BlockService,
LabelService,
CategoryService,
@@ -105,10 +110,11 @@ describe('BlockController', () => {
UserService,
RoleService,
PermissionService,
LanguageService,
PluginService,
LoggerService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -18,7 +18,7 @@ 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 { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
@@ -77,7 +77,7 @@ describe('CategoryController', () => {
},
LoggerService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -19,7 +19,7 @@ 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 { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingService } from '@/setting/services/setting.service';
@@ -92,7 +92,7 @@ describe('MessageController', () => {
MenuService,
MenuRepository,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -11,6 +11,7 @@ import { Injectable, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpSampleCreateDto } from '@/nlp/dto/nlp-sample.dto';
import { NlpSampleState } from '@/nlp/schemas/types';
@@ -36,10 +37,13 @@ export class MessageRepository extends BaseRepository<
private readonly logger: LoggerService;
private readonly languageService: LanguageService;
constructor(
@InjectModel(Message.name) readonly model: Model<AnyMessage>,
@Optional() nlpSampleService?: NlpSampleService,
@Optional() logger?: LoggerService,
@Optional() languageService?: LanguageService,
) {
super(
model,
@@ -49,6 +53,7 @@ export class MessageRepository extends BaseRepository<
);
this.logger = logger;
this.nlpSampleService = nlpSampleService;
this.languageService = languageService;
}
/**
@@ -72,10 +77,13 @@ export class MessageRepository extends BaseRepository<
'message' in _doc &&
'text' in _doc.message
) {
const defaultLang = await this.languageService?.getDefaultLanguage();
const record: NlpSampleCreateDto = {
text: _doc.message.text,
type: NlpSampleState.inbox,
trained: false,
// @TODO : We need to define the language in the message entity
language: defaultLang.id,
};
try {
await this.nlpSampleService.findOneOrCreate(record, record);

View File

@@ -7,6 +7,7 @@
* 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';
@@ -24,11 +25,14 @@ import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
import { Content, ContentModel } from '@/cms/schemas/content.schema';
import { ContentTypeService } from '@/cms/services/content-type.service';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import OfflineHandler from '@/extensions/channels/offline/index.channel';
import { OFFLINE_CHANNEL_NAME } from '@/extensions/channels/offline/settings';
import { Offline } from '@/extensions/channels/offline/types';
import OfflineEventWrapper from '@/extensions/channels/offline/wrapper';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { Settings } from '@/setting/schemas/types';
@@ -94,6 +98,7 @@ describe('BlockService', () => {
ContentModel,
AttachmentModel,
LabelModel,
LanguageModel,
]),
],
providers: [
@@ -102,18 +107,20 @@ describe('BlockService', () => {
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
LanguageRepository,
BlockService,
CategoryService,
ContentTypeService,
ContentService,
AttachmentService,
LanguageService,
{
provide: PluginService,
useValue: {},
},
LoggerService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => {
return t === 'Welcome' ? 'Bienvenue' : t;
@@ -132,6 +139,14 @@ describe('BlockService', () => {
},
},
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
blockService = module.get<BlockService>(BlockService);

View File

@@ -13,7 +13,8 @@ import { Attachment } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import EventWrapper from '@/channel/lib/EventWrapper';
import { ContentService } from '@/cms/services/content.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { Nlp } from '@/nlp/lib/types';
import { PluginService } from '@/plugins/plugins.service';
@@ -44,7 +45,8 @@ export class BlockService extends BaseService<Block, BlockPopulate, BlockFull> {
private readonly settingService: SettingService,
private readonly pluginService: PluginService,
private readonly logger: LoggerService,
protected readonly i18n: ExtendedI18nService,
protected readonly i18n: I18nService,
protected readonly languageService: LanguageService,
) {
super(repository);
}
@@ -109,12 +111,9 @@ export class BlockService extends BaseService<Block, BlockPopulate, BlockFull> {
// Check & catch user language through NLP
const nlp = event.getNLP();
if (nlp) {
const settings = await this.settingService.getSettings();
const languages = await this.languageService.getLanguages();
const lang = nlp.entities.find((e) => e.entity === 'language');
if (
lang &&
settings.nlp_settings.languages.indexOf(lang.value) !== -1
) {
if (lang && Object.keys(languages).indexOf(lang.value) !== -1) {
const profile = event.getSender();
profile.language = lang.value;
event.setSender(profile);
@@ -372,12 +371,11 @@ export class BlockService extends BaseService<Block, BlockPopulate, BlockFull> {
subscriberContext: SubscriberContext,
settings: Settings,
): string {
const lang =
context && context.user && context.user.language
? context.user.language
: settings.nlp_settings.default_lang;
// Translate
text = this.i18n.t(text, { lang, defaultValue: text });
text = this.i18n.t(text, {
lang: context.user.language,
defaultValue: text,
});
// Replace context tokens
text = this.processTokenReplacements(
text,

View File

@@ -25,10 +25,13 @@ import { MenuModel } from '@/cms/schemas/menu.schema';
import { ContentTypeService } from '@/cms/services/content-type.service';
import { ContentService } from '@/cms/services/content.service';
import { MenuService } from '@/cms/services/menu.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { offlineEventText } from '@/extensions/channels/offline/__test__/events.mock';
import OfflineHandler from '@/extensions/channels/offline/index.channel';
import OfflineEventWrapper from '@/extensions/channels/offline/wrapper';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository';
import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository';
@@ -111,6 +114,7 @@ describe('BlockService', () => {
NlpSampleEntityModel,
NlpSampleModel,
ContextVarModel,
LanguageModel,
]),
],
providers: [
@@ -130,6 +134,7 @@ describe('BlockService', () => {
NlpEntityRepository,
NlpSampleEntityRepository,
NlpSampleRepository,
LanguageRepository,
BlockService,
CategoryService,
ContentTypeService,
@@ -149,13 +154,14 @@ describe('BlockService', () => {
NlpService,
ContextVarService,
ContextVarRepository,
LanguageService,
{
provide: PluginService,
useValue: {},
},
LoggerService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -16,7 +16,7 @@ export const config: Config = {
translationFilename: process.env.I18N_TRANSLATION_FILENAME || 'messages',
},
appPath: process.cwd(),
apiPath: process.env.API_ORIGIN,
apiPath: process.env.API_ORIGIN || 'http://localhost:4000',
frontendPath: process.env.FRONTEND_ORIGIN
? process.env.FRONTEND_ORIGIN.split(',')[0]
: 'http://localhost:8080',
@@ -120,10 +120,6 @@ export const config: Config = {
limit: 10,
},
chatbot: {
lang: {
default: 'en',
available: ['en', 'fr'],
},
messages: {
track_delivery: false,
track_read: false,

View File

@@ -15,7 +15,6 @@ type TJwtOptions = {
secret: string;
expiresIn: string;
};
type TLanguage = 'en' | 'fr' | 'ar' | 'tn';
type TMethods = 'GET' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD';
type TLogLevel = 'log' | 'fatal' | 'error' | 'warn' | 'debug' | 'verbose';
type TCacheConfig = {
@@ -87,10 +86,6 @@ export type Config = {
limit: number;
};
chatbot: {
lang: {
default: TLanguage;
available: TLanguage[];
};
messages: {
track_delivery: boolean;
track_read: boolean;

View File

@@ -1,44 +0,0 @@
/*
* 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 { DynamicModule, Global, Inject, Module } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import {
I18N_OPTIONS,
I18N_TRANSLATIONS,
I18nModule,
I18nOptions,
I18nTranslation,
} from 'nestjs-i18n';
import { Observable } from 'rxjs';
import { ExtendedI18nService } from './extended-i18n.service';
@Global()
@Module({})
export class ExtendedI18nModule extends I18nModule {
constructor(
i18n: ExtendedI18nService,
@Inject(I18N_TRANSLATIONS)
translations: Observable<I18nTranslation>,
@Inject(I18N_OPTIONS) i18nOptions: I18nOptions,
adapter: HttpAdapterHost,
) {
super(i18n, translations, i18nOptions, adapter);
}
static forRoot(options: I18nOptions): DynamicModule {
const { providers, exports } = super.forRoot(options);
return {
module: ExtendedI18nModule,
providers: providers.concat(ExtendedI18nService),
exports: exports.concat(ExtendedI18nService),
};
}
}

View File

@@ -15,7 +15,7 @@ import { ChannelService } from '@/channel/channel.service';
import { MessageService } from '@/chat/services/message.service';
import { SubscriberService } from '@/chat/services/subscriber.service';
import { MenuService } from '@/cms/services/menu.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingCreateDto } from '@/setting/dto/setting.dto';
@@ -38,7 +38,7 @@ export default class LiveChatTesterHandler extends OfflineHandler {
nlpService: NlpService,
logger: LoggerService,
eventEmitter: EventEmitter2,
i18n: ExtendedI18nService,
i18n: I18nService,
subscriberService: SubscriberService,
attachmentService: AttachmentService,
messageService: MessageService,

View File

@@ -35,7 +35,7 @@ import { SubscriberService } from '@/chat/services/subscriber.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 { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingService } from '@/setting/services/setting.service';
@@ -113,7 +113,7 @@ describe('Offline Handler', () => {
EventEmitter2,
LoggerService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -25,7 +25,7 @@ import { SubscriberService } from '@/chat/services/subscriber.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 { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingService } from '@/setting/services/setting.service';
@@ -90,7 +90,7 @@ describe(`Offline event wrapper`, () => {
EventEmitter2,
LoggerService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -50,7 +50,7 @@ import { SubscriberService } from '@/chat/services/subscriber.service';
import { Content } from '@/cms/schemas/content.schema';
import { MenuService } from '@/cms/services/menu.service';
import { config } from '@/config';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { SettingCreateDto } from '@/setting/dto/setting.dto';
@@ -73,7 +73,7 @@ export default class OfflineHandler extends ChannelHandler {
nlpService: NlpService,
logger: LoggerService,
protected readonly eventEmitter: EventEmitter2,
protected readonly i18n: ExtendedI18nService,
protected readonly i18n: I18nService,
protected readonly subscriberService: SubscriberService,
protected readonly attachmentService: AttachmentService,
protected readonly messageService: MessageService,
@@ -477,7 +477,7 @@ export default class OfflineHandler extends ChannelHandler {
...channelData,
name: this.getChannel(),
},
language: config.chatbot.lang.default,
language: '',
locale: '',
timezone: 0,
gender: 'male',

View File

@@ -29,6 +29,13 @@ export const baseNlpEntity = {
builtin: true,
};
export const baseLanguage = {
...modelInstance,
title: 'English',
code: 'en',
isDefault: true,
};
export const entitiesMock: NlpEntityFull[] = [
{
...baseNlpEntity,
@@ -89,6 +96,7 @@ export const samplesMock: NlpSampleFull[] = [
],
trained: false,
type: NlpSampleState.train,
language: baseLanguage,
},
{
...modelInstance,
@@ -112,5 +120,6 @@ export const samplesMock: NlpSampleFull[] = [
],
trained: false,
type: NlpSampleState.train,
language: baseLanguage,
},
];

View File

@@ -23,6 +23,10 @@ export const nlpEmptyFormated: DatasetType = {
name: 'product',
elements: ['pizza', 'sandwich'],
},
{
elements: ['en', 'fr'],
name: 'language',
},
],
entity_synonyms: [
{
@@ -34,17 +38,33 @@ export const nlpEmptyFormated: DatasetType = {
export const nlpFormatted: DatasetType = {
common_examples: [
{ text: 'Hello', intent: 'greeting', entities: [] },
{
text: 'Hello',
intent: 'greeting',
entities: [
{
entity: 'language',
value: 'en',
},
],
},
{
text: 'i want to order a pizza',
intent: 'order',
entities: [{ entity: 'product', value: 'pizza', start: 19, end: 23 }],
entities: [
{ entity: 'product', value: 'pizza', start: 19, end: 23 },
{
entity: 'language',
value: 'en',
},
],
},
],
regex_features: [],
lookup_tables: [
{ name: 'intent', elements: ['greeting', 'order'] },
{ name: 'product', elements: ['pizza', 'sandwich'] },
{ name: 'language', elements: ['en', 'fr'] },
],
entity_synonyms: [
{

View File

@@ -8,10 +8,14 @@
*/
import { HttpModule } from '@nestjs/axios';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository';
import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository';
@@ -56,26 +60,11 @@ describe('NLP Default Helper', () => {
NlpValueModel,
NlpSampleModel,
NlpSampleEntityModel,
LanguageModel,
]),
HttpModule,
],
providers: [
LoggerService,
{
provide: SettingService,
useValue: {
getSettings: jest.fn(() => ({
nlp_settings: {
provider: 'default',
endpoint: 'path',
token: 'token',
languages: ['fr', 'ar', 'tn'],
default_lang: 'fr',
threshold: '0.5',
},
})),
},
},
NlpService,
NlpSampleService,
NlpSampleRepository,
@@ -85,8 +74,32 @@ describe('NLP Default Helper', () => {
NlpValueRepository,
NlpSampleEntityService,
NlpSampleEntityRepository,
LanguageService,
LanguageRepository,
EventEmitter2,
DefaultNlpHelper,
LoggerService,
{
provide: SettingService,
useValue: {
getSettings: jest.fn(() => ({
nlp_settings: {
provider: 'default',
endpoint: 'path',
token: 'token',
threshold: '0.5',
},
})),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
settingService = module.get<SettingService>(SettingService);
@@ -103,15 +116,15 @@ describe('NLP Default Helper', () => {
expect(nlp).toBeDefined();
});
it('should format empty training set properly', () => {
it('should format empty training set properly', async () => {
const nlp = nlpService.getNLP();
const results = nlp.format([], entitiesMock);
const results = await nlp.format([], entitiesMock);
expect(results).toEqual(nlpEmptyFormated);
});
it('should format training set properly', () => {
it('should format training set properly', async () => {
const nlp = nlpService.getNLP();
const results = nlp.format(samplesMock, entitiesMock);
const results = await nlp.format(samplesMock, entitiesMock);
expect(results).toEqual(nlpFormatted);
});

View File

@@ -13,21 +13,14 @@ import { Injectable } from '@nestjs/common';
import { LoggerService } from '@/logger/logger.service';
import BaseNlpHelper from '@/nlp/lib/BaseNlpHelper';
import { Nlp } from '@/nlp/lib/types';
import { NlpEntity, NlpEntityFull } from '@/nlp/schemas/nlp-entity.schema';
import { NlpEntityFull } from '@/nlp/schemas/nlp-entity.schema';
import { NlpSampleFull } from '@/nlp/schemas/nlp-sample.schema';
import { NlpValue } from '@/nlp/schemas/nlp-value.schema';
import { NlpEntityService } from '@/nlp/services/nlp-entity.service';
import { NlpSampleService } from '@/nlp/services/nlp-sample.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { buildURL } from '@/utils/helpers/URL';
import {
CommonExample,
DatasetType,
EntitySynonym,
ExampleEntity,
LookupTable,
NlpParseResultType,
} from './types';
import { DatasetType, NlpParseResultType } from './types';
@Injectable()
export default class DefaultNlpHelper extends BaseNlpHelper {
@@ -61,69 +54,16 @@ export default class DefaultNlpHelper extends BaseNlpHelper {
* @param entities - All available entities
* @returns {DatasetType} - The formatted RASA training set
*/
format(samples: NlpSampleFull[], entities: NlpEntityFull[]): DatasetType {
const entityMap = NlpEntity.getEntityMap(entities);
const valueMap = NlpValue.getValueMap(
NlpValue.getValuesFromEntities(entities),
async format(
samples: NlpSampleFull[],
entities: NlpEntityFull[],
): Promise<DatasetType> {
const nluData = await this.nlpSampleService.formatRasaNlu(
samples,
entities,
);
const common_examples: CommonExample[] = samples
.filter((s) => s.entities.length > 0)
.map((s) => {
const intent = s.entities.find(
(e) => entityMap[e.entity].name === 'intent',
);
if (!intent) {
throw new Error('Unable to find the `intent` nlp entity.');
}
const sampleEntities: ExampleEntity[] = s.entities
.filter((e) => entityMap[<string>e.entity].name !== 'intent')
.map((e) => {
const res: ExampleEntity = {
entity: entityMap[<string>e.entity].name,
value: valueMap[<string>e.value].value,
};
if ('start' in e && 'end' in e) {
Object.assign(res, {
start: e.start,
end: e.end,
});
}
return res;
});
return {
text: s.text,
intent: valueMap[intent.value].value,
entities: sampleEntities,
};
});
const lookup_tables: LookupTable[] = entities.map((e) => {
return {
name: e.name,
elements: e.values.map((v) => {
return v.value;
}),
};
});
const entity_synonyms = entities
.reduce((acc, e) => {
const synonyms = e.values.map((v) => {
return {
value: v.value,
synonyms: v.expressions,
};
});
return acc.concat(synonyms);
}, [] as EntitySynonym[])
.filter((s) => {
return s.synonyms.length > 0;
});
return {
common_examples,
regex_features: [],
lookup_tables,
entity_synonyms,
};
return nluData;
}
/**
@@ -138,10 +78,10 @@ export default class DefaultNlpHelper extends BaseNlpHelper {
entities: NlpEntityFull[],
): Promise<any> {
const self = this;
const nluData: DatasetType = self.format(samples, entities);
const nluData: DatasetType = await self.format(samples, entities);
// Train samples
const result = await this.httpService.axiosRef.post(
`${this.settings.endpoint}/train`,
buildURL(this.settings.endpoint, `/train`),
nluData,
{
params: {
@@ -169,10 +109,10 @@ export default class DefaultNlpHelper extends BaseNlpHelper {
entities: NlpEntityFull[],
): Promise<any> {
const self = this;
const nluTestData: DatasetType = self.format(samples, entities);
const nluTestData: DatasetType = await self.format(samples, entities);
// Evaluate model with test samples
return await this.httpService.axiosRef.post(
`${this.settings.endpoint}/evaluate`,
buildURL(this.settings.endpoint, `/evaluate`),
nluTestData,
{
params: {
@@ -251,7 +191,7 @@ export default class DefaultNlpHelper extends BaseNlpHelper {
try {
const { data: nlp } =
await this.httpService.axiosRef.post<NlpParseResultType>(
`${this.settings.endpoint}/parse`,
buildURL(this.settings.endpoint, '/parse'),
{
q: text,
project,

View File

@@ -0,0 +1,181 @@
/*
* 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 { BadRequestException, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import {
installLanguageFixtures,
languageFixtures,
} from '@/utils/test/fixtures/language';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { LanguageController } from './language.controller';
import { LanguageUpdateDto } from '../dto/language.dto';
import { LanguageRepository } from '../repositories/language.repository';
import { Language, LanguageModel } from '../schemas/language.schema';
import { LanguageService } from '../services/language.service';
describe('LanguageController', () => {
let languageController: LanguageController;
let languageService: LanguageService;
let language: Language;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installLanguageFixtures),
MongooseModule.forFeature([LanguageModel]),
],
providers: [
LanguageController,
LanguageService,
LanguageRepository,
LoggerService,
{
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
initDynamicLanguages: jest.fn(),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
LoggerService,
EventEmitter2,
],
}).compile();
languageService = module.get<LanguageService>(LanguageService);
languageController = module.get<LanguageController>(LanguageController);
language = await languageService.findOne({ code: 'en' });
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('count', () => {
it('should count languages', async () => {
jest.spyOn(languageService, 'count');
const result = await languageController.filterCount();
expect(languageService.count).toHaveBeenCalled();
expect(result).toEqual({ count: languageFixtures.length });
});
});
describe('findOne', () => {
it('should find one translation by id', async () => {
jest.spyOn(languageService, 'findOne');
const result = await languageController.findOne(language.id);
expect(languageService.findOne).toHaveBeenCalledWith(language.id);
expect(result).toEqualPayload(
languageFixtures.find(({ code }) => code === language.code),
);
});
});
describe('findPage', () => {
const pageQuery = getPageQuery<Language>({ sort: ['code', 'asc'] });
it('should find languages', async () => {
jest.spyOn(languageService, 'findPage');
const result = await languageController.findPage(pageQuery, {});
expect(languageService.findPage).toHaveBeenCalledWith({}, pageQuery);
expect(result).toEqualPayload(
languageFixtures.sort(({ code: codeA }, { code: codeB }) => {
if (codeA < codeB) {
return -1;
}
if (codeA > codeB) {
return 1;
}
return 0;
}),
);
});
});
describe('updateOne', () => {
const translationUpdateDto: LanguageUpdateDto = {
title: 'English (US)',
};
it('should update one language by id', async () => {
jest.spyOn(languageService, 'updateOne');
const result = await languageController.updateOne(
language.id,
translationUpdateDto,
);
expect(languageService.updateOne).toHaveBeenCalledWith(
language.id,
translationUpdateDto,
);
expect(result).toEqualPayload({
...languageFixtures.find(({ code }) => code === language.code),
...translationUpdateDto,
});
});
it('should mark a language as default', async () => {
jest.spyOn(languageService, 'updateOne');
const translationUpdateDto = { isDefault: true };
const frLang = await languageService.findOne({ code: 'fr' });
const result = await languageController.updateOne(
frLang.id,
translationUpdateDto,
);
expect(languageService.updateOne).toHaveBeenCalledWith(
frLang.id,
translationUpdateDto,
);
expect(result).toEqualPayload({
...languageFixtures.find(({ code }) => code === frLang.code),
...translationUpdateDto,
});
const enLang = await languageService.findOne({ code: 'en' });
expect(enLang.isDefault).toBe(false);
});
it('should throw a NotFoundException when attempting to update a translation by id', async () => {
jest.spyOn(languageService, 'updateOne');
await expect(
languageController.updateOne(NOT_FOUND_ID, translationUpdateDto),
).rejects.toThrow(NotFoundException);
});
});
describe('deleteOne', () => {
it('should throw when attempting to delete the default language', async () => {
const defaultLang = await languageService.findOne({ isDefault: true });
await expect(
languageController.deleteOne(defaultLang.id),
).rejects.toThrow(BadRequestException);
});
});
});

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 {
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 { 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 { LanguageCreateDto, LanguageUpdateDto } from '../dto/language.dto';
import { Language } from '../schemas/language.schema';
import { LanguageService } from '../services/language.service';
@UseInterceptors(CsrfInterceptor)
@Controller('language')
export class LanguageController extends BaseController<Language> {
constructor(
private readonly languageService: LanguageService,
private readonly logger: LoggerService,
) {
super(languageService);
}
/**
* 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 language search.
* @returns A Promise that resolves to a paginated list of categories.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Language>,
@Query(new SearchFilterPipe<Language>({ allowedFields: ['title', 'code'] }))
filters: TFilterQuery<Language>,
) {
return await this.languageService.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<Language>({
allowedFields: ['title', 'code'],
}),
)
filters?: TFilterQuery<Language>,
) {
return await this.count(filters);
}
/**
* Finds a language by its ID.
* @param id - The ID of the language to find.
* @returns A Promise that resolves to the found language.
*/
@Get(':id')
async findOne(@Param('id') id: string): Promise<Language> {
const doc = await this.languageService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Language by id ${id}`);
throw new NotFoundException(`Language with ID ${id} not found`);
}
return doc;
}
/**
* Creates a new language.
* @param language - The data of the language to be created.
* @returns A Promise that resolves to the created language.
*/
@CsrfCheck(true)
@Post()
async create(@Body() language: LanguageCreateDto): Promise<Language> {
return await this.languageService.create(language);
}
/**
* Updates an existing language.
* @param id - The ID of the language to be updated.
* @param languageUpdate - The updated data for the language.
* @returns A Promise that resolves to the updated language.
*/
@CsrfCheck(true)
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() languageUpdate: LanguageUpdateDto,
): Promise<Language> {
if ('isDefault' in languageUpdate) {
if (languageUpdate.isDefault) {
// A new default language is define, make sure that only one is marked as default
await this.languageService.updateMany({}, { isDefault: false });
} else {
throw new BadRequestException('Should not be able to disable default');
}
}
const result = await this.languageService.updateOne(id, languageUpdate);
if (!result) {
this.logger.warn(`Unable to update Language by id ${id}`);
throw new NotFoundException(`Language with ID ${id} not found`);
}
return result;
}
/**
* Deletes a language by its ID.
* @param id - The ID of the language 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.languageService.deleteOne({
isDefault: false, // Prevent deleting the default language
_id: id,
});
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Language by id ${id}`);
throw new BadRequestException(`Unable to delete Language with ID ${id}`);
}
return result;
}
}

View File

@@ -17,13 +17,23 @@ import { AttachmentRepository } from '@/attachment/repositories/attachment.repos
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ChannelService } from '@/channel/channel.service';
import { MessageController } from '@/chat/controllers/message.controller';
import { BlockRepository } from '@/chat/repositories/block.repository';
import { MessageRepository } from '@/chat/repositories/message.repository';
import { SubscriberRepository } from '@/chat/repositories/subscriber.repository';
import { BlockModel } from '@/chat/schemas/block.schema';
import { MessageModel } from '@/chat/schemas/message.schema';
import { SubscriberModel } from '@/chat/schemas/subscriber.schema';
import { BlockService } from '@/chat/services/block.service';
import { MessageService } from '@/chat/services/message.service';
import { SubscriberService } from '@/chat/services/subscriber.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 { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { PluginService } from '@/plugins/plugins.service';
@@ -39,20 +49,13 @@ import {
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 { LanguageRepository } from '../repositories/language.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 { LanguageModel } from '../schemas/language.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 { LanguageService } from '../services/language.service';
import { TranslationService } from '../services/translation.service';
describe('TranslationController', () => {
@@ -73,6 +76,7 @@ describe('TranslationController', () => {
MenuModel,
BlockModel,
ContentModel,
LanguageModel,
]),
],
providers: [
@@ -114,10 +118,10 @@ describe('TranslationController', () => {
EventEmitter2,
LoggerService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
initDynamicTranslations: jest.fn(),
refreshDynamicTranslations: jest.fn(),
},
},
{
@@ -129,6 +133,8 @@ describe('TranslationController', () => {
},
},
LoggerService,
LanguageService,
LanguageRepository,
],
}).compile();
translationService = module.get<TranslationService>(TranslationService);

View File

@@ -8,15 +8,18 @@
*/
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
Query,
UseInterceptors,
Post,
} from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
@@ -25,18 +28,21 @@ 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 { 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 { TranslationUpdateDto } from '../dto/translation.dto';
import { Translation } from '../schemas/translation.schema';
import { LanguageService } from '../services/language.service';
import { TranslationService } from '../services/translation.service';
@UseInterceptors(CsrfInterceptor)
@Controller('translation')
export class TranslationController extends BaseController<Translation> {
constructor(
private readonly languageService: LanguageService,
private readonly translationService: TranslationService,
private readonly settingService: SettingService,
private readonly logger: LoggerService,
@@ -103,40 +109,56 @@ export class TranslationController extends BaseController<Translation> {
@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 },
);
const defaultLanguage = await this.languageService.getDefaultLanguage();
const languages = await this.languageService.getLanguages();
const defaultTrans: Translation['translations'] = Object.keys(languages)
.filter((lang) => lang !== defaultLanguage.code)
.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 },
});
});
});
let strings = await this.translationService.getAllBlockStrings();
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 },
),
);
await Promise.all(queue);
// Purge non existing translations
return this.translationService.deleteMany({
str: { $nin: strings },
});
}
/**
* Deletes a translation by its ID.
* @param id - The ID of the translation 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.translationService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete Translation by id ${id}`);
throw new BadRequestException(
`Unable to delete Translation with ID ${id}`,
);
}
return result;
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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 { PartialType } from '@nestjs/mapped-types';
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class LanguageCreateDto {
@ApiProperty({ description: 'Language Title', type: String })
@IsNotEmpty()
@IsString()
title: string;
@ApiProperty({ description: 'Language Code', type: String })
@IsNotEmpty()
@IsString()
code: string;
@ApiProperty({ description: 'Whether Language is RTL', type: Boolean })
@IsBoolean()
isRTL: boolean;
@ApiProperty({ description: 'Is Default Language ?', type: Boolean })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
export class LanguageUpdateDto extends PartialType(LanguageCreateDto) {}

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 {
DynamicModule,
forwardRef,
Global,
Inject,
Module,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { MongooseModule } from '@nestjs/mongoose';
import {
I18N_OPTIONS,
I18N_TRANSLATIONS,
I18nOptions,
I18nTranslation,
I18nModule as NativeI18nModule,
} from 'nestjs-i18n';
import { Observable } from 'rxjs';
import { ChatModule } from '@/chat/chat.module';
import { LanguageController } from './controllers/language.controller';
import { TranslationController } from './controllers/translation.controller';
import { LanguageRepository } from './repositories/language.repository';
import { TranslationRepository } from './repositories/translation.repository';
import { LanguageModel } from './schemas/language.schema';
import { TranslationModel } from './schemas/translation.schema';
import { LanguageSeeder } from './seeds/language.seed';
import { TranslationSeeder } from './seeds/translation.seed';
import { I18nService } from './services/i18n.service';
import { LanguageService } from './services/language.service';
import { TranslationService } from './services/translation.service';
@Global()
@Module({})
export class I18nModule extends NativeI18nModule {
constructor(
i18n: I18nService,
@Inject(I18N_TRANSLATIONS)
translations: Observable<I18nTranslation>,
@Inject(I18N_OPTIONS) i18nOptions: I18nOptions,
adapter: HttpAdapterHost,
) {
super(i18n, translations, i18nOptions, adapter);
}
static forRoot(options: I18nOptions): DynamicModule {
const { imports, providers, controllers, exports } = super.forRoot(options);
return {
module: I18nModule,
imports: (imports || []).concat([
MongooseModule.forFeature([LanguageModel, TranslationModel]),
forwardRef(() => ChatModule),
]),
controllers: (controllers || []).concat([
LanguageController,
TranslationController,
]),
providers: providers.concat([
I18nService,
LanguageRepository,
LanguageService,
LanguageSeeder,
TranslationRepository,
TranslationService,
TranslationSeeder,
]),
exports: exports.concat(I18nService, LanguageService),
};
}
}

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 { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query, TFilterQuery } from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { Language } from '../schemas/language.schema';
@Injectable()
export class LanguageRepository extends BaseRepository<Language> {
constructor(
@InjectModel(Language.name) readonly model: Model<Language>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Language);
}
/**
* Pre-delete hook that triggers before an language is deleted.
*
* @param query The query used to delete the language.
* @param criteria The filter criteria used to find the language for deletion.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Language, any, any>,
unknown,
Language,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<Language>,
): Promise<void> {
if (criteria._id) {
const language = await this.findOne(
typeof criteria === 'string' ? { _id: criteria } : criteria,
);
this.eventEmitter.emit('hook:language:delete', language);
} else {
throw new Error('Attempted to delete language using unknown criteria');
}
}
}

View File

@@ -14,7 +14,7 @@ import { Document, Model, Query, Types } from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { Translation } from '../schemas/translation.schema';
import { Translation } from '../../i18n/schemas/translation.schema';
@Injectable()
export class TranslationRepository extends BaseRepository<Translation> {

View File

@@ -0,0 +1,52 @@
/*
* 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 Language extends BaseSchema {
@Prop({
type: String,
required: true,
unique: true,
})
title: string;
@Prop({
type: String,
required: true,
unique: true,
})
code: string;
@Prop({
type: Boolean,
default: false,
})
isDefault?: boolean;
@Prop({
type: Boolean,
default: false,
})
isRTL?: boolean;
}
export const LanguageModel: ModelDefinition = LifecycleHookManager.attach({
name: Language.name,
schema: SchemaFactory.createForClass(Language),
});
export type LanguageDocument = THydratedDocument<Language>;
export default LanguageModel.schema;

View File

@@ -11,6 +11,7 @@ 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 Translation extends BaseSchema {
@@ -26,17 +27,12 @@ export class Translation extends BaseSchema {
required: true,
})
translations: Record<string, string>;
@Prop({
type: Number,
})
translated: number;
}
export const TranslationModel: ModelDefinition = {
export const TranslationModel: ModelDefinition = LifecycleHookManager.attach({
name: Translation.name,
schema: SchemaFactory.createForClass(Translation),
};
});
export type TranslationDocument = THydratedDocument<Translation>;

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 { LanguageCreateDto } from '../dto/language.dto';
export const languageModels: LanguageCreateDto[] = [
{
title: 'English',
code: 'en',
isRTL: false,
isDefault: true,
},
{
title: 'Français',
code: 'fr',
isRTL: false,
},
];

View File

@@ -0,0 +1,22 @@
/*
* 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 { BaseSeeder } from '@/utils/generics/base-seeder';
import { LanguageRepository } from '../repositories/language.repository';
import { Language } from '../schemas/language.schema';
@Injectable()
export class LanguageSeeder extends BaseSeeder<Language> {
constructor(private readonly languageRepository: LanguageRepository) {
super(languageRepository);
}
}

View File

@@ -8,22 +8,22 @@
*/
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService, Path, PathValue, TranslateOptions } from 'nestjs-i18n';
import {
I18nService as NativeI18nService,
Path,
PathValue,
TranslateOptions,
} from 'nestjs-i18n';
import { IfAnyOrNever } from 'nestjs-i18n/dist/types';
import { Translation } from './chat/schemas/translation.schema';
import { config } from './config';
import { config } from '@/config';
import { Translation } from '@/i18n/schemas/translation.schema';
@Injectable()
export class ExtendedI18nService<
export class I18nService<
K = Record<string, unknown>,
> extends I18nService<K> {
private dynamicTranslations: Record<string, Record<string, string>> =
config.chatbot.lang.available.reduce(
(acc, curr) => ({ ...acc, [curr]: {} }),
{},
);
> extends NativeI18nService<K> {
private dynamicTranslations: Record<string, Record<string, string>> = {};
t<P extends Path<K> = any, R = PathValue<K, P>>(
key: P,
@@ -35,17 +35,19 @@ export class ExtendedI18nService<
...options,
};
let { lang } = options;
lang = lang ?? this.i18nOptions.fallbackLanguage;
lang = this.resolveLanguage(lang);
// Translate block message, button text, ...
if (lang in this.dynamicTranslations) {
if (key in this.dynamicTranslations[lang]) {
return this.dynamicTranslations[lang][key] as IfAnyOrNever<
R,
string,
R
>;
if (this.dynamicTranslations[lang][key]) {
return this.dynamicTranslations[lang][key] as IfAnyOrNever<
R,
string,
R
>;
}
return options.defaultValue as IfAnyOrNever<R, string, R>;
}
}
@@ -54,15 +56,13 @@ export class ExtendedI18nService<
return super.t<P, R>(key, options);
}
@OnEvent('hook:i18n:refresh')
initDynamicTranslations(translations: Translation[]) {
refreshDynamicTranslations(translations: Translation[]) {
this.dynamicTranslations = translations.reduce((acc, curr) => {
const { str, translations } = curr;
Object.entries(translations)
.filter(([lang]) => lang in acc)
.forEach(([lang, t]) => {
acc[lang][str] = t;
});
Object.entries(translations).forEach(([lang, t]) => {
acc[lang] = acc[lang] || {};
acc[lang][str] = t;
});
return acc;
}, this.dynamicTranslations);

View File

@@ -0,0 +1,68 @@
/*
* 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 { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import {
DEFAULT_LANGUAGE_CACHE_KEY,
LANGUAGES_CACHE_KEY,
} from '@/utils/constants/cache';
import { Cacheable } from '@/utils/decorators/cacheable.decorator';
import { BaseService } from '@/utils/generics/base-service';
import { LanguageRepository } from '../repositories/language.repository';
import { Language } from '../schemas/language.schema';
@Injectable()
export class LanguageService extends BaseService<Language> {
constructor(
readonly repository: LanguageRepository,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {
super(repository);
}
/**
* Retrieves all available languages from the repository.
*
* @returns A promise that resolves to an object where each key is a language code
* and the corresponding value is the `Language` object.
*/
@Cacheable(LANGUAGES_CACHE_KEY)
async getLanguages() {
const languages = await this.findAll();
return languages.reduce((acc, curr) => {
return {
...acc,
[curr.code]: curr,
};
}, {});
}
/**
* Retrieves the default language from the repository.
*
* @returns A promise that resolves to the default `Language` object.
*/
@Cacheable(DEFAULT_LANGUAGE_CACHE_KEY)
async getDefaultLanguage() {
return await this.findOne({ isDefault: true });
}
/**
* Retrieves the language by code.
*
* @returns A promise that resolves to the `Language` object.
*/
async getLanguageByCode(code: string) {
return await this.findOne({ code });
}
}

View File

@@ -0,0 +1,266 @@
/*
* 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 { Test, TestingModule } from '@nestjs/testing';
import { I18nService } from '@/i18n/services/i18n.service';
import { Settings } from '@/setting/schemas/types';
import { SettingService } from '@/setting/services/setting.service';
import { Block } from '../../chat/schemas/block.schema';
import { BlockOptions } from '../../chat/schemas/types/options';
import { BlockService } from '../../chat/services/block.service';
import { TranslationRepository } from '../repositories/translation.repository';
import { TranslationService } from '../services/translation.service';
describe('TranslationService', () => {
let service: TranslationService;
let settingService: SettingService;
let i18nService: I18nService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TranslationService,
{
provide: TranslationRepository,
useValue: {
findAll: jest.fn().mockResolvedValue([
{
key: 'test',
value: 'test',
lang: 'en',
},
]),
},
},
{
provide: BlockService,
useValue: {
find: jest.fn().mockResolvedValue([
{
id: 'blockId',
message: ['Test message'],
options: {
fallback: {
message: ['Fallback message'],
},
},
} as Block,
]),
},
},
{
provide: SettingService,
useValue: {
getSettings: jest.fn().mockResolvedValue({
chatbot_settings: {
global_fallback: true,
fallback_message: ['Global fallback message'],
},
} as Settings),
},
},
{
provide: I18nService,
useValue: {
refreshDynamicTranslations: jest.fn(),
},
},
EventEmitter2,
],
}).compile();
service = module.get<TranslationService>(TranslationService);
settingService = module.get<SettingService>(SettingService);
i18nService = module.get<I18nService>(I18nService);
});
it('should call refreshDynamicTranslations with translations from findAll', async () => {
jest.spyOn(i18nService, 'refreshDynamicTranslations');
await service.resetI18nTranslations();
expect(i18nService.refreshDynamicTranslations).toHaveBeenCalledWith([
{
key: 'test',
value: 'test',
lang: 'en',
},
]);
});
it('should return an array of strings from all blocks', async () => {
const strings = await service.getAllBlockStrings();
expect(strings).toEqual(['Test message', 'Fallback message']);
});
it('should return an array of strings from the settings when global fallback is enabled', async () => {
const strings = await service.getSettingStrings();
expect(strings).toEqual(['Global fallback message']);
});
it('should return an empty array from the settings when global fallback is disabled', async () => {
jest.spyOn(settingService, 'getSettings').mockResolvedValueOnce({
chatbot_settings: {
global_fallback: false,
fallback_message: ['Global fallback message'],
},
} as Settings);
const strings = await service.getSettingStrings();
expect(strings).toEqual([]);
});
it('should return an array of strings from a block with a quick reply message', () => {
const block = {
id: 'blockId',
name: 'Test Block',
category: 'Test Category',
position: { x: 0, y: 0 },
message: {
text: 'Test message',
quickReplies: [
{
title: 'Quick reply 1',
},
{
title: 'Quick reply 2',
},
],
},
options: {
fallback: {
active: true,
message: ['Fallback message'],
max_attempts: 3,
} as BlockOptions,
},
createdAt: new Date(),
updatedAt: new Date(),
} as Block;
const strings = service.getBlockStrings(block);
expect(strings).toEqual([
'Test message',
'Quick reply 1',
'Quick reply 2',
'Fallback message',
]);
});
it('should return an array of strings from a block with a button message', () => {
const block = {
id: 'blockId',
name: 'Test Block',
category: 'Test Category',
position: { x: 0, y: 0 },
message: {
text: 'Test message',
buttons: [
{
title: 'Button 1',
},
{
title: 'Button 2',
},
],
},
options: {
fallback: {
active: true,
message: ['Fallback message'],
max_attempts: 3,
} as BlockOptions,
},
createdAt: new Date(),
updatedAt: new Date(),
} as Block;
const strings = service.getBlockStrings(block);
expect(strings).toEqual([
'Test message',
'Button 1',
'Button 2',
'Fallback message',
]);
});
it('should return an array of strings from a block with a text message', () => {
const block = {
id: 'blockId',
name: 'Test Block',
category: 'Test Category',
position: { x: 0, y: 0 },
message: ['Test message'], // Text message as an array
options: {
fallback: {
active: true,
message: ['Fallback message'],
max_attempts: 3,
} as BlockOptions,
},
createdAt: new Date(),
updatedAt: new Date(),
} as Block;
const strings = service.getBlockStrings(block);
expect(strings).toEqual(['Test message', 'Fallback message']);
});
it('should return an array of strings from a block with a nested message object', () => {
const block = {
id: 'blockId',
name: 'Test Block',
category: 'Test Category',
position: { x: 0, y: 0 },
message: {
text: 'Test message', // Nested text message
},
options: {
fallback: {
active: true,
message: ['Fallback message'],
max_attempts: 3,
} as BlockOptions,
},
createdAt: new Date(),
updatedAt: new Date(),
} as Block;
const strings = service.getBlockStrings(block);
expect(strings).toEqual(['Test message', 'Fallback message']);
});
it('should handle different message formats in getBlockStrings', () => {
// Covers lines 54-60, 65
// Test with an array message (line 54-57)
const block1 = {
id: 'blockId1',
message: ['This is a text message'],
options: { fallback: { message: ['Fallback message'] } },
} as Block;
const strings1 = service.getBlockStrings(block1);
expect(strings1).toEqual(['This is a text message', 'Fallback message']);
// Test with an object message (line 58-60)
const block2 = {
id: 'blockId2',
message: { text: 'Another text message' },
options: { fallback: { message: ['Fallback message'] } },
} as Block;
const strings2 = service.getBlockStrings(block2);
expect(strings2).toEqual(['Another text message', 'Fallback message']);
// Test a block without a fallback (line 65)
const block3 = {
id: 'blockId3',
message: { text: 'Another test message' },
options: {},
} as Block;
const strings3 = service.getBlockStrings(block3);
expect(strings3).toEqual(['Another test message']);
});
});

View File

@@ -10,13 +10,13 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { SettingService } from '@/setting/services/setting.service';
import { BaseService } from '@/utils/generics/base-service';
import { BlockService } from './block.service';
import { Block } from '../../chat/schemas/block.schema';
import { BlockService } from '../../chat/services/block.service';
import { TranslationRepository } from '../repositories/translation.repository';
import { Block } from '../schemas/block.schema';
import { Translation } from '../schemas/translation.schema';
@Injectable()
@@ -25,7 +25,7 @@ export class TranslationService extends BaseService<Translation> {
readonly repository: TranslationRepository,
private readonly blockService: BlockService,
private readonly settingService: SettingService,
private readonly i18n: ExtendedI18nService,
private readonly i18n: I18nService,
) {
super(repository);
this.resetI18nTranslations();
@@ -33,7 +33,7 @@ export class TranslationService extends BaseService<Translation> {
public async resetI18nTranslations() {
const translations = await this.findAll();
this.i18n.initDynamicTranslations(translations);
this.i18n.refreshDynamicTranslations(translations);
}
/**

View File

@@ -51,6 +51,7 @@ async function bootstrap() {
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
// forbidNonWhitelisted: true,
}),
new ObjectIdPipe(),

View File

@@ -18,7 +18,10 @@ import { Test, TestingModule } 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 { ExtendedI18nService } from '@/extended-i18n.service';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { Language, LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { SettingRepository } from '@/setting/repositories/setting.repository';
import { SettingModel } from '@/setting/schemas/setting.schema';
@@ -57,7 +60,9 @@ describe('NlpSampleController', () => {
let nlpEntityService: NlpEntityService;
let nlpValueService: NlpValueService;
let attachmentService: AttachmentService;
let languageService: LanguageService;
let byeJhonSampleId: string;
let languages: Language[];
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -74,6 +79,7 @@ describe('NlpSampleController', () => {
NlpEntityModel,
NlpValueModel,
SettingModel,
LanguageModel,
]),
],
providers: [
@@ -88,13 +94,15 @@ describe('NlpSampleController', () => {
NlpValueRepository,
NlpSampleService,
NlpSampleEntityService,
LanguageRepository,
LanguageService,
EventEmitter2,
NlpService,
SettingRepository,
SettingService,
SettingSeeder,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
@@ -122,6 +130,8 @@ describe('NlpSampleController', () => {
})
).id;
attachmentService = module.get<AttachmentService>(AttachmentService);
languageService = module.get<LanguageService>(LanguageService);
languages = await languageService.findAll();
});
afterAll(async () => {
await closeInMongodConnection();
@@ -134,7 +144,7 @@ describe('NlpSampleController', () => {
const pageQuery = getPageQuery<NlpSample>({ sort: ['text', 'desc'] });
const result = await nlpSampleController.findPage(
pageQuery,
['entities'],
['language', 'entities'],
{},
);
const nlpSamples = await nlpSampleService.findAll();
@@ -146,6 +156,7 @@ describe('NlpSampleController', () => {
entities: nlpSampleEntities.filter((currSampleEntity) => {
return currSampleEntity.sample === currSample.id;
}),
language: languages.find((lang) => lang.id === currSample.language),
};
acc.push(sampleWithEntities);
return acc;
@@ -163,7 +174,12 @@ describe('NlpSampleController', () => {
['invalidCriteria'],
{},
);
expect(result).toEqualPayload(nlpSampleFixtures);
expect(result).toEqualPayload(
nlpSampleFixtures.map((sample) => ({
...sample,
language: languages[sample.language].id,
})),
);
});
});
@@ -177,14 +193,19 @@ describe('NlpSampleController', () => {
describe('create', () => {
it('should create nlp sample', async () => {
const enLang = await languageService.findOne({ code: 'en' });
const nlSample: NlpSampleDto = {
text: 'text1',
trained: true,
type: NlpSampleState.test,
entities: [],
language: 'en',
};
const result = await nlpSampleController.create(nlSample);
expect(result).toEqualPayload(nlSample);
expect(result).toEqualPayload({
...nlSample,
language: enLang,
});
});
});
@@ -209,7 +230,10 @@ describe('NlpSampleController', () => {
const result = await nlpSampleController.findOne(yessSample.id, [
'invalidCreteria',
]);
expect(result).toEqualPayload(nlpSampleFixtures[0]);
expect(result).toEqualPayload({
...nlpSampleFixtures[0],
language: languages[nlpSampleFixtures[0].language].id,
});
});
it('should find a nlp sample and populate its entities', async () => {
@@ -225,6 +249,7 @@ describe('NlpSampleController', () => {
const samplesWithEntities = {
...nlpSampleFixtures[0],
entities: [yessSampleEntity],
language: languages[nlpSampleFixtures[0].language],
};
expect(result).toEqualPayload(samplesWithEntities);
});
@@ -241,6 +266,9 @@ describe('NlpSampleController', () => {
const yessSample = await nlpSampleService.findOne({
text: 'yess',
});
const frLang = await languageService.findOne({
code: 'fr',
});
const result = await nlpSampleController.updateOne(yessSample.id, {
text: 'updated',
trained: true,
@@ -251,6 +279,7 @@ describe('NlpSampleController', () => {
value: 'update',
},
],
language: 'fr',
});
const updatedSample = {
text: 'updated',
@@ -263,11 +292,13 @@ describe('NlpSampleController', () => {
value: expect.stringMatching(/^[a-z0-9]+$/),
},
],
language: frLang,
};
expect(result.text).toEqual(updatedSample.text);
expect(result.type).toEqual(updatedSample.type);
expect(result.trained).toEqual(updatedSample.trained);
expect(result.entities).toMatchObject(updatedSample.entities);
expect(result.language).toEqualPayload(updatedSample.language);
});
it('should throw exception when nlp sample id not found', async () => {
@@ -276,6 +307,7 @@ describe('NlpSampleController', () => {
text: 'updated',
trained: true,
type: NlpSampleState.test,
language: 'fr',
}),
).rejects.toThrow(NotFoundException);
});
@@ -352,7 +384,7 @@ describe('NlpSampleController', () => {
).id;
const mockCsvData: string = [
`text,intent,language`,
`Was kostet dieser bmw,preis,de`,
`How much does a BMW cost?,price,en`,
].join('\n');
jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true);
jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockCsvData);
@@ -361,17 +393,14 @@ describe('NlpSampleController', () => {
const intentEntityResult = await nlpEntityService.findOne({
name: 'intent',
});
const languageEntityResult = await nlpEntityService.findOne({
name: 'language',
});
const preisValueResult = await nlpValueService.findOne({
value: 'preis',
});
const deValueResult = await nlpValueService.findOne({
value: 'de',
const priceValueResult = await nlpValueService.findOne({
value: 'price',
});
const textSampleResult = await nlpSampleService.findOne({
text: 'Was kostet dieser bmw',
text: 'How much does a BMW cost?',
});
const language = await languageService.findOne({
code: 'en',
});
const intentEntity = {
name: 'intent',
@@ -379,40 +408,24 @@ describe('NlpSampleController', () => {
doc: '',
builtin: false,
};
const languageEntity = {
name: 'language',
lookups: ['trait'],
builtin: false,
doc: '',
};
const preisVlueEntity = await nlpEntityService.findOne({
const priceValueEntity = await nlpEntityService.findOne({
name: 'intent',
});
const preisValue = {
value: 'preis',
const priceValue = {
value: 'price',
expressions: [],
builtin: false,
entity: preisVlueEntity.id,
};
const deValueEntity = await nlpEntityService.findOne({
name: 'language',
});
const deValue = {
value: 'de',
expressions: [],
builtin: false,
entity: deValueEntity.id,
entity: priceValueEntity.id,
};
const textSample = {
text: 'Was kostet dieser bmw',
text: 'How much does a BMW cost?',
trained: false,
type: 'train',
language: language.id,
};
expect(languageEntityResult).toEqualPayload(languageEntity);
expect(intentEntityResult).toEqualPayload(intentEntity);
expect(preisValueResult).toEqualPayload(preisValue);
expect(deValueResult).toEqualPayload(deValue);
expect(priceValueResult).toEqualPayload(priceValue);
expect(textSampleResult).toEqualPayload(textSample);
expect(result).toEqual({ success: true });
});

View File

@@ -34,6 +34,7 @@ import Papa from 'papaparse';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { config } from '@/config';
import { LanguageService } from '@/i18n/services/language.service';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseController } from '@/utils/generics/base-controller';
@@ -70,6 +71,7 @@ export class NlpSampleController extends BaseController<
private readonly nlpEntityService: NlpEntityService,
private readonly logger: LoggerService,
private readonly nlpService: NlpService,
private readonly languageService: LanguageService,
) {
super(nlpSampleService);
}
@@ -91,7 +93,7 @@ export class NlpSampleController extends BaseController<
type ? { type } : {},
);
const entities = await this.nlpEntityService.findAllAndPopulate();
const result = this.nlpSampleService.formatRasaNlu(samples, entities);
const result = await this.nlpSampleService.formatRasaNlu(samples, entities);
// Sending the JSON data as a file
const buffer = Buffer.from(JSON.stringify(result));
@@ -120,11 +122,18 @@ export class NlpSampleController extends BaseController<
@CsrfCheck(true)
@Post()
async create(
@Body() { entities: nlpEntities, ...createNlpSampleDto }: NlpSampleDto,
@Body()
{
entities: nlpEntities,
language: languageCode,
...createNlpSampleDto
}: NlpSampleDto,
): Promise<NlpSampleFull> {
const nlpSample = await this.nlpSampleService.create(
createNlpSampleDto as NlpSampleCreateDto,
);
const language = await this.languageService.getLanguageByCode(languageCode);
const nlpSample = await this.nlpSampleService.create({
...createNlpSampleDto,
language: language.id,
});
const entities = await this.nlpSampleEntityService.storeSampleEntities(
nlpSample,
@@ -134,6 +143,7 @@ export class NlpSampleController extends BaseController<
return {
...nlpSample,
entities,
language,
};
}
@@ -243,7 +253,11 @@ export class NlpSampleController extends BaseController<
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<NlpSample>,
@Query(PopulatePipe) populate: string[],
@Query(new SearchFilterPipe<NlpSample>({ allowedFields: ['text', 'type'] }))
@Query(
new SearchFilterPipe<NlpSample>({
allowedFields: ['text', 'type', 'language'],
}),
)
filters: TFilterQuery<NlpSample>,
) {
return this.canPopulate(populate)
@@ -263,12 +277,12 @@ export class NlpSampleController extends BaseController<
@Patch(':id')
async updateOne(
@Param('id') id: string,
@Body() updateNlpSampleDto: NlpSampleDto,
@Body() { entities, language: languageCode, ...sampleAttrs }: NlpSampleDto,
): Promise<NlpSampleFull> {
const { text, type, entities } = updateNlpSampleDto;
const language = await this.languageService.getLanguageByCode(languageCode);
const sample = await this.nlpSampleService.updateOne(id, {
text,
type,
...sampleAttrs,
language: language.id,
trained: false,
});
@@ -284,6 +298,7 @@ export class NlpSampleController extends BaseController<
return {
...sample,
language,
entities: updatedSampleEntities,
};
}
@@ -366,6 +381,8 @@ export class NlpSampleController extends BaseController<
}
// Remove data with no intent
const filteredData = result.data.filter((d) => d.intent !== 'none');
const languages = await this.languageService.getLanguages();
const defaultLanguage = await this.languageService.getDefaultLanguage();
// Reduce function to ensure executing promises one by one
for (const d of filteredData) {
try {
@@ -375,15 +392,25 @@ export class NlpSampleController extends BaseController<
});
// Skip if sample already exists
if (Array.isArray(existingSamples) && existingSamples.length > 0) {
continue;
}
// Fallback to default language if 'language' is missing or invalid
if (!d.language || !(d.language in languages)) {
if (d.language) {
this.logger.warn(
`Language "${d.language}" does not exist, falling back to default.`,
);
}
d.language = defaultLanguage.code;
}
// Create a new sample dto
const sample: NlpSampleCreateDto = {
text: d.text,
trained: false,
language: languages[d.language].id,
};
// Create a new sample entity dto

View File

@@ -16,27 +16,38 @@ import {
IsString,
} from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
import { NlpSampleEntityValue, NlpSampleState } from '../schemas/types';
export class NlpSampleCreateDto {
@ApiProperty({ description: 'nlp sample text', type: String })
@ApiProperty({ description: 'NLP sample text', type: String })
@IsString()
@IsNotEmpty()
text: string;
@ApiPropertyOptional({ description: 'nlp sample is trained', type: Boolean })
@ApiPropertyOptional({
description: 'If NLP sample is trained',
type: Boolean,
})
@IsBoolean()
@IsOptional()
trained?: boolean;
@ApiPropertyOptional({
description: 'nlp sample type',
description: 'NLP sample type',
enum: Object.values(NlpSampleState),
})
@IsString()
@IsIn(Object.values(NlpSampleState))
@IsOptional()
type?: NlpSampleState;
@ApiProperty({ description: 'NLP sample language id', type: String })
@IsString()
@IsNotEmpty()
@IsObjectId({ message: 'Language must be a valid ObjectId' })
language: string;
}
export class NlpSampleDto extends NlpSampleCreateDto {
@@ -45,6 +56,11 @@ export class NlpSampleDto extends NlpSampleCreateDto {
})
@IsOptional()
entities?: NlpSampleEntityValue[];
@ApiProperty({ description: 'NLP sample language code', type: String })
@IsString()
@IsNotEmpty()
language: string;
}
export class NlpSampleUpdateDto extends PartialType(NlpSampleCreateDto) {}

View File

@@ -11,6 +11,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { Language, LanguageModel } from '@/i18n/schemas/language.schema';
import { nlpSampleFixtures } from '@/utils/test/fixtures/nlpsample';
import {
installNlpSampleEntityFixtures,
@@ -37,8 +39,10 @@ import { NlpValueModel } from '../schemas/nlp-value.schema';
describe('NlpSampleEntityRepository', () => {
let nlpSampleEntityRepository: NlpSampleEntityRepository;
let nlpEntityRepository: NlpEntityRepository;
let languageRepository: LanguageRepository;
let nlpSampleEntities: NlpSampleEntity[];
let nlpEntities: NlpEntity[];
let languages: Language[];
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -49,12 +53,14 @@ describe('NlpSampleEntityRepository', () => {
NlpEntityModel,
NlpValueModel,
NlpSampleModel,
LanguageModel,
]),
],
providers: [
NlpSampleEntityRepository,
NlpEntityRepository,
NlpValueRepository,
LanguageRepository,
EventEmitter2,
],
}).compile();
@@ -62,8 +68,10 @@ describe('NlpSampleEntityRepository', () => {
NlpSampleEntityRepository,
);
nlpEntityRepository = module.get<NlpEntityRepository>(NlpEntityRepository);
languageRepository = module.get<LanguageRepository>(LanguageRepository);
nlpSampleEntities = await nlpSampleEntityRepository.findAll();
nlpEntities = await nlpEntityRepository.findAll();
languages = await languageRepository.findAll();
});
afterAll(async () => {
@@ -81,7 +89,10 @@ describe('NlpSampleEntityRepository', () => {
...nlpSampleEntityFixtures[0],
entity: nlpEntities[0],
value: { ...nlpValueFixtures[0], entity: nlpEntities[0].id },
sample: nlpSampleFixtures[0],
sample: {
...nlpSampleFixtures[0],
language: languages[nlpSampleFixtures[0].language].id,
},
});
});
});
@@ -117,7 +128,10 @@ describe('NlpSampleEntityRepository', () => {
...curr,
entity: nlpEntities[curr.entity],
value: nlpValueFixturesWithEntities[curr.value],
sample: nlpSampleFixtures[curr.sample],
sample: {
...nlpSampleFixtures[curr.sample],
language: languages[nlpSampleFixtures[curr.sample].language].id,
},
};
acc.push(sampleEntityWithPopulate);
return acc;

View File

@@ -11,6 +11,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { Language, LanguageModel } from '@/i18n/schemas/language.schema';
import { nlpSampleFixtures } from '@/utils/test/fixtures/nlpsample';
import { installNlpSampleEntityFixtures } from '@/utils/test/fixtures/nlpsampleentity';
import { getPageQuery } from '@/utils/test/pagination';
@@ -30,18 +32,25 @@ import { NlpSampleModel, NlpSample } from '../schemas/nlp-sample.schema';
describe('NlpSampleRepository', () => {
let nlpSampleRepository: NlpSampleRepository;
let nlpSampleEntityRepository: NlpSampleEntityRepository;
let languageRepository: LanguageRepository;
let nlpSampleEntity: NlpSampleEntity;
let noNlpSample: NlpSample;
let languages: Language[];
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installNlpSampleEntityFixtures),
MongooseModule.forFeature([NlpSampleModel, NlpSampleEntityModel]),
MongooseModule.forFeature([
NlpSampleModel,
NlpSampleEntityModel,
LanguageModel,
]),
],
providers: [
NlpSampleRepository,
NlpSampleEntityRepository,
LanguageRepository,
EventEmitter2,
],
}).compile();
@@ -49,10 +58,12 @@ describe('NlpSampleRepository', () => {
nlpSampleEntityRepository = module.get<NlpSampleEntityRepository>(
NlpSampleEntityRepository,
);
languageRepository = module.get<LanguageRepository>(LanguageRepository);
noNlpSample = await nlpSampleRepository.findOne({ text: 'No' });
nlpSampleEntity = await nlpSampleEntityRepository.findOne({
sample: noNlpSample.id,
});
languages = await languageRepository.findAll();
});
afterAll(async () => {
@@ -69,6 +80,7 @@ describe('NlpSampleRepository', () => {
expect(result).toEqualPayload({
...nlpSampleFixtures[1],
entities: [nlpSampleEntity],
language: languages[nlpSampleFixtures[1].language],
});
});
});
@@ -92,6 +104,7 @@ describe('NlpSampleRepository', () => {
entities: nlpSampleEntities.filter((currSampleEntity) => {
return currSampleEntity.sample === currSample.id;
}),
language: languages.find((lang) => currSample.language === lang.id),
};
acc.push(sampleWithEntities);
return acc;

View File

@@ -8,9 +8,10 @@
*/
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Exclude, Type } from 'class-transformer';
import { THydratedDocument } from 'mongoose';
import { Exclude, Transform, Type } from 'class-transformer';
import { THydratedDocument, Schema as MongooseSchema } from 'mongoose';
import { Language } from '@/i18n/schemas/language.schema';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { TFilterPopulateFields } from '@/utils/types/filter.types';
@@ -41,16 +42,32 @@ export class NlpSampleStub extends BaseSchema {
default: NlpSampleState.train,
})
type?: keyof typeof NlpSampleState;
/**
* The language of the sample.
*/
@Prop({
type: MongooseSchema.Types.ObjectId,
ref: 'Language',
required: false,
})
language: unknown | null;
}
@Schema({ timestamps: true })
export class NlpSample extends NlpSampleStub {
@Transform(({ obj }) => obj.language.toString())
language: string | null;
@Exclude()
entities?: never;
}
@Schema({ timestamps: true })
export class NlpSampleFull extends NlpSampleStub {
@Type(() => Language)
language: Language | null;
@Type(() => NlpSampleEntity)
entities: NlpSampleEntity[];
}
@@ -75,4 +92,7 @@ export type NlpSamplePopulate = keyof TFilterPopulateFields<
NlpSampleStub
>;
export const NLP_SAMPLE_POPULATE: NlpSamplePopulate[] = ['entities'];
export const NLP_SAMPLE_POPULATE: NlpSamplePopulate[] = [
'language',
'entities',
];

View File

@@ -10,12 +10,6 @@
import { NlpEntityCreateDto } from '../dto/nlp-entity.dto';
export const nlpEntityModels: NlpEntityCreateDto[] = [
{
name: 'language',
lookups: ['trait'],
doc: `"language" refers to the language of the text sent by the end user`,
builtin: true,
},
{
name: 'intent',
lookups: ['trait'],

View File

@@ -7,16 +7,6 @@
* 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';
import { NlpValueCreateDto } from '../dto/nlp-value.dto';
export const nlpValueModels: NlpValueCreateDto[] = [
...config.chatbot.lang.available.map((lang: string) => {
return {
entity: 'language',
value: lang,
builtin: true,
};
}),
];
export const nlpValueModels: NlpValueCreateDto[] = [];

View File

@@ -11,6 +11,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { Language, LanguageModel } from '@/i18n/schemas/language.schema';
import { nlpSampleFixtures } from '@/utils/test/fixtures/nlpsample';
import {
installNlpSampleEntityFixtures,
@@ -42,7 +44,9 @@ describe('NlpSampleEntityService', () => {
let nlpSampleEntityRepository: NlpSampleEntityRepository;
let nlpSampleEntities: NlpSampleEntity[];
let nlpEntityRepository: NlpEntityRepository;
let languageRepository: LanguageRepository;
let nlpEntities: NlpEntity[];
let languages: Language[];
let nlpEntityService: NlpEntityService;
let nlpValueService: NlpValueService;
@@ -55,12 +59,14 @@ describe('NlpSampleEntityService', () => {
NlpEntityModel,
NlpSampleModel,
NlpValueModel,
LanguageModel,
]),
],
providers: [
NlpSampleEntityRepository,
NlpEntityRepository,
NlpValueRepository,
LanguageRepository,
NlpSampleEntityService,
NlpEntityService,
NlpValueService,
@@ -74,6 +80,7 @@ describe('NlpSampleEntityService', () => {
NlpSampleEntityRepository,
);
nlpEntityRepository = module.get<NlpEntityRepository>(NlpEntityRepository);
languageRepository = module.get<LanguageRepository>(LanguageRepository);
nlpSampleEntityService = module.get<NlpSampleEntityService>(
NlpSampleEntityService,
);
@@ -81,6 +88,7 @@ describe('NlpSampleEntityService', () => {
nlpValueService = module.get<NlpValueService>(NlpValueService);
nlpSampleEntities = await nlpSampleEntityRepository.findAll();
nlpEntities = await nlpEntityRepository.findAll();
languages = await languageRepository.findAll();
});
afterAll(async () => {
@@ -98,7 +106,10 @@ describe('NlpSampleEntityService', () => {
...nlpSampleEntityFixtures[0],
entity: nlpEntities[0],
value: { ...nlpValueFixtures[0], entity: nlpEntities[0].id },
sample: nlpSampleFixtures[0],
sample: {
...nlpSampleFixtures[0],
language: languages[nlpSampleFixtures[0].language].id,
},
};
expect(result).toEqualPayload(sampleEntityWithPopulate);
});
@@ -135,7 +146,10 @@ describe('NlpSampleEntityService', () => {
...curr,
entity: nlpEntities[curr.entity],
value: nlpValueFixturesWithEntities[curr.value],
sample: nlpSampleFixtures[curr.sample],
sample: {
...nlpSampleFixtures[curr.sample],
language: languages[nlpSampleFixtures[curr.sample].language].id,
},
};
acc.push(sampleEntityWithPopulate);
return acc;

View File

@@ -7,10 +7,14 @@
* 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, TestingModule } from '@nestjs/testing';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { Language, LanguageModel } from '@/i18n/schemas/language.schema';
import { LanguageService } from '@/i18n/services/language.service';
import { nlpSampleFixtures } from '@/utils/test/fixtures/nlpsample';
import { installNlpSampleEntityFixtures } from '@/utils/test/fixtures/nlpsampleentity';
import { getPageQuery } from '@/utils/test/pagination';
@@ -39,8 +43,10 @@ describe('NlpSampleService', () => {
let nlpSampleService: NlpSampleService;
let nlpSampleEntityRepository: NlpSampleEntityRepository;
let nlpSampleRepository: NlpSampleRepository;
let languageRepository: LanguageRepository;
let noNlpSample: NlpSample;
let nlpSampleEntity: NlpSampleEntity;
let languages: Language[];
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -51,6 +57,7 @@ describe('NlpSampleService', () => {
NlpSampleEntityModel,
NlpValueModel,
NlpEntityModel,
LanguageModel,
]),
],
providers: [
@@ -58,11 +65,21 @@ describe('NlpSampleService', () => {
NlpSampleEntityRepository,
NlpEntityRepository,
NlpValueRepository,
LanguageRepository,
NlpSampleService,
NlpSampleEntityService,
NlpEntityService,
NlpValueService,
LanguageService,
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
nlpSampleService = module.get<NlpSampleService>(NlpSampleService);
@@ -73,10 +90,12 @@ describe('NlpSampleService', () => {
nlpSampleEntityRepository = module.get<NlpSampleEntityRepository>(
NlpSampleEntityRepository,
);
languageRepository = module.get<LanguageRepository>(LanguageRepository);
noNlpSample = await nlpSampleService.findOne({ text: 'No' });
nlpSampleEntity = await nlpSampleEntityRepository.findOne({
sample: noNlpSample.id,
});
languages = await languageRepository.findAll();
});
afterAll(async () => {
@@ -91,6 +110,7 @@ describe('NlpSampleService', () => {
const sampleWithEntities = {
...nlpSampleFixtures[1],
entities: [nlpSampleEntity],
language: languages[nlpSampleFixtures[1].language],
};
expect(result).toEqualPayload(sampleWithEntities);
});
@@ -110,6 +130,7 @@ describe('NlpSampleService', () => {
entities: nlpSampleEntities.filter((currSampleEntity) => {
return currSampleEntity.sample === currSample.id;
}),
language: languages.find((lang) => lang.id === currSample.language),
};
acc.push(sampleWithEntities);
return acc;

View File

@@ -8,6 +8,7 @@
*/
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
CommonExample,
@@ -16,6 +17,8 @@ import {
ExampleEntity,
LookupTable,
} from '@/extensions/helpers/nlp/default/types';
import { Language } from '@/i18n/schemas/language.schema';
import { LanguageService } from '@/i18n/services/language.service';
import { BaseService } from '@/utils/generics/base-service';
import { NlpSampleRepository } from '../repositories/nlp-sample.repository';
@@ -33,7 +36,10 @@ export class NlpSampleService extends BaseService<
NlpSamplePopulate,
NlpSampleFull
> {
constructor(readonly repository: NlpSampleRepository) {
constructor(
readonly repository: NlpSampleRepository,
private readonly languageService: LanguageService,
) {
super(repository);
}
@@ -56,10 +62,10 @@ export class NlpSampleService extends BaseService<
*
* @returns The formatted Rasa NLU training dataset.
*/
formatRasaNlu(
async formatRasaNlu(
samples: NlpSampleFull[],
entities: NlpEntityFull[],
): DatasetType {
): Promise<DatasetType> {
const entityMap = NlpEntity.getEntityMap(entities);
const valueMap = NlpValue.getValueMap(
NlpValue.getValuesFromEntities(entities),
@@ -88,21 +94,34 @@ export class NlpSampleService extends BaseService<
});
}
return res;
})
// TODO : place language at the same level as the intent
.concat({
entity: 'language',
value: s.language.code,
});
return {
text: s.text,
intent: valueMap[intent.value].value,
entities: sampleEntities,
};
});
const lookup_tables: LookupTable[] = entities.map((e) => {
return {
name: e.name,
elements: e.values.map((v) => {
return v.value;
}),
};
});
const languages = await this.languageService.getLanguages();
const lookup_tables: LookupTable[] = entities
.map((e) => {
return {
name: e.name,
elements: e.values.map((v) => {
return v.value;
}),
};
})
.concat({
name: 'language',
elements: Object.keys(languages),
});
const entity_synonyms = entities
.reduce((acc, e) => {
const synonyms = e.values.map((v) => {
@@ -123,4 +142,21 @@ export class NlpSampleService extends BaseService<
entity_synonyms,
};
}
/**
* When a language gets deleted, we need to set related samples to null
*
* @param language The language that has been deleted.
*/
@OnEvent('hook:language:delete')
async handleLanguageDelete(language: Language) {
await this.updateMany(
{
language: language.id,
},
{
language: null,
},
);
}
}

View File

@@ -13,8 +13,10 @@ import { CategorySeeder } from './chat/seeds/category.seed';
import { categoryModels } from './chat/seeds/category.seed-model';
import { ContextVarSeeder } from './chat/seeds/context-var.seed';
import { contextVarModels } from './chat/seeds/context-var.seed-model';
import { TranslationSeeder } from './chat/seeds/translation.seed';
import { translationModels } from './chat/seeds/translation.seed-model';
import { LanguageSeeder } from './i18n/seeds/language.seed';
import { languageModels } from './i18n/seeds/language.seed-model';
import { TranslationSeeder } from './i18n/seeds/translation.seed';
import { translationModels } from './i18n/seeds/translation.seed-model';
import { LoggerService } from './logger/logger.service';
import { NlpEntitySeeder } from './nlp/seeds/nlp-entity.seed';
import { nlpEntityModels } from './nlp/seeds/nlp-entity.seed-model';
@@ -40,6 +42,7 @@ export async function seedDatabase(app: INestApplicationContext) {
const settingSeeder = app.get(SettingSeeder);
const permissionSeeder = app.get(PermissionSeeder);
const userSeeder = app.get(UserSeeder);
const languageSeeder = app.get(LanguageSeeder);
const translationSeeder = app.get(TranslationSeeder);
const nlpEntitySeeder = app.get(NlpEntitySeeder);
const nlpValueSeeder = app.get(NlpValueSeeder);
@@ -127,6 +130,14 @@ export async function seedDatabase(app: INestApplicationContext) {
throw e;
}
// Seed languages
try {
await languageSeeder.seed(languageModels);
} catch (e) {
logger.error('Unable to seed the database with languages!');
throw e;
}
// Seed translations
try {
await translationSeeder.seed(translationModels);

View File

@@ -12,7 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import {
installSettingFixtures,
@@ -47,7 +47,7 @@ describe('SettingController', () => {
LoggerService,
EventEmitter2,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -12,8 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query, Types } from 'mongoose';
import { config } from '@/config';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { BaseRepository } from '@/utils/generics/base-repository';
import { Setting } from '../schemas/setting.schema';
@@ -23,7 +22,7 @@ export class SettingRepository extends BaseRepository<Setting> {
constructor(
@InjectModel(Setting.name) readonly model: Model<Setting>,
private readonly eventEmitter: EventEmitter2,
private readonly i18n: ExtendedI18nService,
private readonly i18n: I18nService,
) {
super(model, Setting);
}
@@ -65,8 +64,7 @@ export class SettingRepository extends BaseRepository<Setting> {
* Emits an event after a `Setting` has been updated.
*
* This method is used to synchronize global settings by emitting an event
* based on the `group` and `label` of the `Setting`. It also updates the i18n
* default language setting when the `default_lang` label is updated.
* based on the `group` and `label` of the `Setting`.
*
* @param _query The Mongoose query object used to find and update the document.
* @param setting The updated `Setting` object.
@@ -86,33 +84,5 @@ export class SettingRepository extends BaseRepository<Setting> {
'hook:settings:' + setting.group + ':' + setting.label,
setting,
);
if (setting.label === 'default_lang') {
// @todo : check if this actually updates the default lang
this.i18n.resolveLanguage(setting.value as string);
}
}
/**
* Sets default values before creating a `Setting` document.
*
* If the setting is part of the `nlp_settings` group, it sets specific values
* for `languages` and `default_lang` labels, using configuration values from the
* chatbot settings.
*
* @param setting The `Setting` document to be created.
*/
async preCreate(
setting: Document<unknown, unknown, Setting> &
Setting & { _id: Types.ObjectId },
) {
if (setting.group === 'nlp_settings') {
if (setting.label === 'languages') {
setting.value = config.chatbot.lang.available;
} else if (setting.label === 'default_lang') {
setting.value = config.chatbot.lang.default;
setting.options = config.chatbot.lang.available;
}
}
}
}

View File

@@ -98,8 +98,6 @@ export type SettingDict = { [group: string]: Setting[] };
export type Settings = {
nlp_settings: {
default_lang: string;
languages: string[];
threshold: string;
provider: string;
endpoint: string;

View File

@@ -7,8 +7,6 @@
* 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';
import { SettingCreateDto } from '../dto/setting.dto';
import { SettingType } from '../schemas/types';
@@ -67,26 +65,6 @@ export const settingModels: SettingCreateDto[] = [
type: SettingType.text,
weight: 3,
},
{
group: 'nlp_settings',
label: 'languages',
value: [],
options: [],
type: SettingType.select,
config: {
multiple: true,
allowCreate: true,
},
weight: 4,
},
{
group: 'nlp_settings',
label: 'default_lang',
value: config.chatbot.lang.default,
options: [], // NOTE : will be set onBeforeCreate from config
type: SettingType.select,
weight: 5,
},
{
group: 'nlp_settings',
label: 'threshold',
@@ -97,7 +75,7 @@ export const settingModels: SettingCreateDto[] = [
max: 1,
step: 0.01,
},
weight: 6,
weight: 4,
},
{
group: 'contact',

View File

@@ -12,7 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import {
installSettingFixtures,
@@ -51,7 +51,7 @@ describe('SettingService', () => {
SettingSeeder,
EventEmitter2,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -23,7 +23,10 @@ import { SentMessageInfo } from 'nodemailer';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { installUserFixtures } from '@/utils/test/fixtures/user';
import {
@@ -69,6 +72,7 @@ describe('AuthController', () => {
PermissionModel,
InvitationModel,
AttachmentModel,
LanguageModel,
]),
],
providers: [
@@ -86,6 +90,8 @@ describe('AuthController', () => {
PermissionRepository,
InvitationRepository,
InvitationService,
LanguageRepository,
LanguageService,
JwtService,
{
provide: MailerService,
@@ -106,7 +112,7 @@ describe('AuthController', () => {
EventEmitter2,
ValidateAccountService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -20,7 +20,10 @@ import { SentMessageInfo } from 'nodemailer';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import { installPermissionFixtures } from '@/utils/test/fixtures/permission';
@@ -75,6 +78,7 @@ describe('UserController', () => {
PermissionModel,
InvitationModel,
AttachmentModel,
LanguageModel,
]),
JwtModule,
],
@@ -108,9 +112,11 @@ describe('UserController', () => {
},
AttachmentService,
AttachmentRepository,
LanguageService,
LanguageRepository,
ValidateAccountService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -35,6 +35,7 @@ describe('PermissionRepository', () => {
let permissionRepository: PermissionRepository;
let permissionModel: Model<Permission>;
let permission: Permission;
let permissionToDelete: Permission;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -59,6 +60,9 @@ describe('PermissionRepository', () => {
permission = await permissionRepository.findOne({
action: Action.CREATE,
});
permissionToDelete = await permissionRepository.findOne({
action: Action.UPDATE,
});
});
afterAll(async () => {
@@ -112,4 +116,36 @@ describe('PermissionRepository', () => {
expect(result).toEqualPayload(permissionsWithRolesAndModels);
});
});
describe('deleteOne', () => {
it('should delete a permission by id', async () => {
jest.spyOn(permissionModel, 'deleteOne');
const result = await permissionRepository.deleteOne(
permissionToDelete.id,
);
expect(permissionModel.deleteOne).toHaveBeenCalledWith({
_id: permissionToDelete.id,
});
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
const permissions = await permissionRepository.find({
role: permissionToDelete.id,
});
expect(permissions.length).toEqual(0);
});
it('should fail to delete a permission that does not exist', async () => {
expect(
await permissionRepository.deleteOne(permissionToDelete.id),
).toEqual({
acknowledged: true,
deletedCount: 0,
});
});
});
});

View File

@@ -24,8 +24,8 @@ import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { PermissionModel } from '../schemas/permission.schema';
import { RoleModel, Role } from '../schemas/role.schema';
import { UserModel, User } from '../schemas/user.schema';
import { Role, RoleModel } from '../schemas/role.schema';
import { User, UserModel } from '../schemas/user.schema';
describe('RoleRepository', () => {
let roleRepository: RoleRepository;
@@ -34,6 +34,7 @@ describe('RoleRepository', () => {
let roleModel: Model<Role>;
let role: Role;
let users: User[];
let roleToDelete: Role;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -57,6 +58,9 @@ describe('RoleRepository', () => {
users = (await userRepository.findAll()).filter((user) =>
user.roles.includes(role.id),
);
roleToDelete = await roleRepository.findOne({
name: 'manager',
});
});
afterAll(async () => {
@@ -106,4 +110,31 @@ describe('RoleRepository', () => {
expect(result).toEqualPayload(rolesWithPermissionsAndUsers);
});
});
describe('deleteOne', () => {
it('should delete a role by id', async () => {
jest.spyOn(roleModel, 'deleteOne');
const result = await roleRepository.deleteOne(roleToDelete.id);
expect(roleModel.deleteOne).toHaveBeenCalledWith({
_id: roleToDelete.id,
});
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
const permissions = await permissionRepository.find({
role: roleToDelete.id,
});
expect(permissions.length).toEqual(0);
});
it('should fail to delete a role that does not exist', async () => {
expect(await roleRepository.deleteOne(roleToDelete.id)).toEqual({
acknowledged: true,
deletedCount: 0,
});
});
});
});

View File

@@ -96,7 +96,7 @@ export class RoleRepository extends BaseRepository<
*
* @returns The result of the delete operation.
*/
async deleteOneQuery(id: string) {
async deleteOne(id: string) {
const result = await this.model.deleteOne({ _id: id }).exec();
if (result.deletedCount > 0) {
await this.permissionModel.deleteMany({ role: id });

View File

@@ -100,6 +100,11 @@ export const modelModels: ModelCreateDto[] = [
identity: 'subscriber',
attributes: {},
},
{
name: 'Language',
identity: 'language',
attributes: {},
},
{
name: 'Translation',
identity: 'translation',

View File

@@ -16,7 +16,10 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer';
import { SentMessageInfo } from 'nodemailer';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
@@ -55,6 +58,7 @@ describe('InvitationService', () => {
RoleModel,
PermissionModel,
InvitationModel,
LanguageModel,
]),
JwtModule,
],
@@ -66,10 +70,12 @@ describe('InvitationService', () => {
PermissionRepository,
InvitationRepository,
InvitationService,
LanguageRepository,
LanguageService,
JwtService,
Logger,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -17,7 +17,8 @@ import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import { MailerService } from '@nestjs-modules/mailer';
import { config } from '@/config';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service';
@@ -41,7 +42,8 @@ export class InvitationService extends BaseService<
@Inject(JwtService) private readonly jwtService: JwtService,
@Optional() private readonly mailerService: MailerService | undefined,
private logger: LoggerService,
protected readonly i18n: ExtendedI18nService,
protected readonly i18n: I18nService,
public readonly languageService: LanguageService,
) {
super(repository);
}
@@ -63,6 +65,7 @@ export class InvitationService extends BaseService<
const jwt = await this.sign(dto);
if (this.mailerService) {
try {
const defaultLanguage = await this.languageService.getDefaultLanguage();
await this.mailerService.sendMail({
to: dto.email,
template: 'invitation.mjml',
@@ -70,7 +73,7 @@ export class InvitationService extends BaseService<
token: jwt,
// TODO: Which language should we use?
t: (key: string) =>
this.i18n.t(key, { lang: config.chatbot.lang.default }),
this.i18n.t(key, { lang: defaultLanguage.code }),
},
subject: this.i18n.t('invitation_subject'),
});

View File

@@ -21,7 +21,10 @@ import { SentMessageInfo } from 'nodemailer';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { installUserFixtures, users } from '@/utils/test/fixtures/user';
import {
@@ -52,6 +55,7 @@ describe('PasswordResetService', () => {
RoleModel,
PermissionModel,
AttachmentModel,
LanguageModel,
]),
JwtModule,
],
@@ -62,6 +66,8 @@ describe('PasswordResetService', () => {
AttachmentService,
AttachmentRepository,
RoleRepository,
LanguageService,
LanguageRepository,
LoggerService,
PasswordResetService,
JwtService,
@@ -75,7 +81,7 @@ describe('PasswordResetService', () => {
},
},
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},

View File

@@ -21,7 +21,8 @@ import { MailerService } from '@nestjs-modules/mailer';
import { compareSync } from 'bcryptjs';
import { config } from '@/config';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { UserService } from './user.service';
@@ -34,7 +35,8 @@ export class PasswordResetService {
@Optional() private readonly mailerService: MailerService | undefined,
private logger: LoggerService,
private readonly userService: UserService,
public readonly i18n: ExtendedI18nService,
public readonly i18n: I18nService,
public readonly languageService: LanguageService,
) {}
public readonly jwtSignOptions: JwtSignOptions = {
@@ -59,6 +61,7 @@ export class PasswordResetService {
if (this.mailerService) {
try {
const defaultLanguage = await this.languageService.getDefaultLanguage();
await this.mailerService.sendMail({
to: dto.email,
template: 'password_reset.mjml',
@@ -66,7 +69,7 @@ export class PasswordResetService {
token: jwt,
first_name: user.first_name,
t: (key: string) =>
this.i18n.t(key, { lang: config.chatbot.lang.default }),
this.i18n.t(key, { lang: defaultLanguage.code }),
},
subject: this.i18n.t('password_reset_subject'),
});

View File

@@ -7,6 +7,7 @@
* 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 { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
@@ -17,7 +18,10 @@ import { SentMessageInfo } from 'nodemailer';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { installUserFixtures, users } from '@/utils/test/fixtures/user';
import {
@@ -46,6 +50,7 @@ describe('ValidateAccountService', () => {
RoleModel,
PermissionModel,
AttachmentModel,
LanguageModel,
]),
JwtModule,
],
@@ -56,6 +61,8 @@ describe('ValidateAccountService', () => {
UserRepository,
RoleService,
RoleRepository,
LanguageService,
LanguageRepository,
LoggerService,
{
provide: MailerService,
@@ -69,11 +76,19 @@ describe('ValidateAccountService', () => {
EventEmitter2,
ValidateAccountService,
{
provide: ExtendedI18nService,
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => t),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
validateAccountService = module.get<ValidateAccountService>(

View File

@@ -18,7 +18,9 @@ import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import { MailerService } from '@nestjs-modules/mailer';
import { config } from '@/config';
import { ExtendedI18nService } from '@/extended-i18n.service';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { LoggerService } from '@/logger/logger.service';
import { UserService } from './user.service';
import { UserCreateDto } from '../dto/user.dto';
@@ -35,7 +37,9 @@ export class ValidateAccountService {
@Inject(JwtService) private readonly jwtService: JwtService,
private readonly userService: UserService,
@Optional() private readonly mailerService: MailerService | undefined,
private readonly i18n: ExtendedI18nService,
private logger: LoggerService,
private readonly i18n: I18nService,
private readonly languageService: LanguageService,
) {}
/**
@@ -73,17 +77,28 @@ export class ValidateAccountService {
const confirmationToken = await this.sign({ email: dto.email });
if (this.mailerService) {
await this.mailerService.sendMail({
to: dto.email,
template: 'account_confirmation.mjml',
context: {
token: confirmationToken,
first_name: dto.first_name,
t: (key: string) =>
this.i18n.t(key, { lang: config.chatbot.lang.default }),
},
subject: this.i18n.t('account_confirmation_subject'),
});
try {
const defaultLanguage = await this.languageService.getDefaultLanguage();
await this.mailerService.sendMail({
to: dto.email,
template: 'account_confirmation.mjml',
context: {
token: confirmationToken,
first_name: dto.first_name,
t: (key: string) =>
this.i18n.t(key, { lang: defaultLanguage.code }),
},
subject: this.i18n.t('account_confirmation_subject'),
});
} catch (e) {
this.logger.error(
'Could not send email',
e.message,
e.stack,
'ValidateAccount',
);
throw new InternalServerErrorException('Could not send email');
}
}
}

View File

@@ -26,6 +26,7 @@ export type TModel =
| 'conversation'
| 'message'
| 'subscriber'
| 'language'
| 'translation'
| 'botstats'
| 'menu'

View File

@@ -13,3 +13,7 @@ export const SETTING_CACHE_KEY = 'settings';
export const PERMISSION_CACHE_KEY = 'permissions';
export const MENU_CACHE_KEY = 'menu';
export const LANGUAGES_CACHE_KEY = 'languages';
export const DEFAULT_LANGUAGE_CACHE_KEY = 'default_language';

View File

@@ -0,0 +1,9 @@
export const buildURL = (baseUrl: string, relativePath: string): string => {
try {
const url = new URL(relativePath, baseUrl);
return url.toString();
} catch {
throw new Error(`Invalid base URL: ${baseUrl}`);
}
};

View File

@@ -13,6 +13,7 @@ import {
ArgumentMetadata,
Logger,
} from '@nestjs/common';
import escapeRegExp from 'lodash/escapeRegExp';
import { TFilterQuery, Types } from 'mongoose';
import {
@@ -36,9 +37,8 @@ export class SearchFilterPipe<T>
}
private getRegexValue(val: string) {
const quote = (str: string) =>
str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
return new RegExp(quote(val), 'i');
const escapedRegExp = escapeRegExp(val);
return new RegExp(escapedRegExp, 'i');
}
private isAllowedField(field: string) {

33
api/src/utils/test/fixtures/language.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
/*
* 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 { LanguageUpdateDto } from '@/i18n/dto/language.dto';
import { LanguageModel } from '@/i18n/schemas/language.schema';
export const languageFixtures: LanguageUpdateDto[] = [
{
title: 'English',
code: 'en',
isDefault: true,
isRTL: false,
},
{
title: 'Français',
code: 'fr',
isDefault: false,
isRTL: false,
},
];
export const installLanguageFixtures = async () => {
const Language = mongoose.model(LanguageModel.name, LanguageModel.schema);
return await Language.insertMany(languageFixtures);
};

View File

@@ -25,12 +25,6 @@ export const nlpEntityFixtures: NlpEntityCreateDto[] = [
doc: '',
builtin: false,
},
{
name: 'language',
lookups: ['trait'],
doc: '',
builtin: false,
},
{
name: 'built_in',
lookups: ['trait'],

View File

@@ -13,23 +13,28 @@ import { NlpSampleCreateDto } from '@/nlp/dto/nlp-sample.dto';
import { NlpSampleModel, NlpSample } from '@/nlp/schemas/nlp-sample.schema';
import { NlpSampleState } from '@/nlp/schemas/types';
import { installLanguageFixtures } from './language';
import { getFixturesWithDefaultValues } from '../defaultValues';
import { TFixturesDefaultValues } from '../types';
const nlpSamples: NlpSampleCreateDto[] = [
{
text: 'yess',
language: '0',
},
{
text: 'No',
language: '0',
},
{
text: 'Hello',
trained: true,
language: '0',
},
{
text: 'Bye Jhon',
trained: true,
language: '0',
},
];
@@ -44,6 +49,15 @@ export const nlpSampleFixtures = getFixturesWithDefaultValues<NlpSample>({
});
export const installNlpSampleFixtures = async () => {
const languages = await installLanguageFixtures();
const NlpSample = mongoose.model(NlpSampleModel.name, NlpSampleModel.schema);
return await NlpSample.insertMany(nlpSampleFixtures);
return await NlpSample.insertMany(
nlpSampleFixtures.map((v) => {
return {
...v,
language: languages[parseInt(v.language)].id,
};
}),
);
};

View File

@@ -45,12 +45,6 @@ export const nlpValueFixtures: NlpValueCreateDto[] = [
expressions: ['bye', 'bye bye'],
builtin: true,
},
{
entity: '2',
value: 'en',
expressions: [],
builtin: true,
},
];
export const installNlpValueFixtures = async () => {

View File

@@ -9,8 +9,8 @@
import mongoose from 'mongoose';
import { TranslationUpdateDto } from '@/chat/dto/translation.dto';
import { TranslationModel } from '@/chat/schemas/translation.schema';
import { TranslationUpdateDto } from '@/i18n/dto/translation.dto';
import { TranslationModel } from '@/i18n/schemas/translation.schema';
export const translationFixtures: TranslationUpdateDto[] = [
{

View File

@@ -45,7 +45,8 @@ AUTH_TOKEN=token123
LANGUAGE_CLASSIFIER=language-classifier
INTENT_CLASSIFIERS=en,fr
TFLC_REPO_ID=Hexastack/tflc
JISF_REPO_ID=Hexastack/jisf
INTENT_CLASSIFIER_REPO_ID=Hexastack/intent-classifier
SLOT_FILLER_REPO_ID=Hexastack/slot-filler
NLP_PORT=5000
# Frontend (Next.js)

View File

@@ -5,5 +5,6 @@ services:
build:
context: ../nlu
dockerfile: Dockerfile
pull_policy: build
ports:
- ${NLP_PORT}:5000

View File

@@ -1,5 +1 @@
version: "3.8"
widget:
build:
target: production

View File

@@ -7,6 +7,7 @@
* 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 { CheckCircle } from "@mui/icons-material";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettingsOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EditIcon from "@mui/icons-material/EditOutlined";
@@ -39,12 +40,16 @@ export enum ActionColumnLabel {
Content = "button.content",
Fields = "button.fields",
Manage_Labels = "title.manage_labels",
Toggle = "button.toggle",
}
export interface ActionColumn<T extends GridValidRowModel> {
label: ActionColumnLabel;
action?: (row: T) => void;
requires?: PermissionAction[];
getState?: (row: T) => boolean;
helperText?: string;
isDisabled?: (row: T) => boolean;
}
const BUTTON_WIDTH = 60;
@@ -70,6 +75,8 @@ function getIcon(label: ActionColumnLabel) {
return <TocOutlinedIcon />;
case ActionColumnLabel.Manage_Labels:
return <LocalOfferIcon />;
case ActionColumnLabel.Toggle:
return <CheckCircle />;
default:
return <></>;
}
@@ -78,7 +85,7 @@ function getIcon(label: ActionColumnLabel) {
function getColor(label: ActionColumnLabel) {
switch (label) {
case ActionColumnLabel.Edit:
return theme.palette.warning.main;
return theme.palette.grey[900];
case ActionColumnLabel.Delete:
return theme.palette.error.main;
default:
@@ -97,29 +104,46 @@ function StackComponent<T extends GridValidRowModel>({
return (
<Stack height="100%" alignItems="center" direction="row" spacing={0.5}>
{actions.map(({ label, action, requires = [] }) => (
<GridActionsCellItem
key={label}
className="actionButton"
icon={<Tooltip title={t(label)}>{getIcon(label)}</Tooltip>}
label={t(label)}
showInMenu={false}
sx={{
color: "grey",
"&:hover": {
color: getColor(label),
},
}}
disabled={
params.row.builtin &&
(requires.includes(PermissionAction.UPDATE) ||
requires.includes(PermissionAction.DELETE))
}
onClick={() => {
action && action(params.row);
}}
/>
))}
{actions.map(
({
label,
action,
requires = [],
getState,
helperText,
isDisabled,
}) => (
<GridActionsCellItem
key={label}
className="actionButton"
icon={
<Tooltip title={helperText || t(label)}>{getIcon(label)}</Tooltip>
}
label={helperText || t(label)}
showInMenu={false}
sx={{
color:
label === ActionColumnLabel.Toggle &&
getState &&
getState(params.row)
? getColor(label)
: theme.palette.grey[600],
"&:hover": {
color: getColor(label),
},
}}
disabled={
(isDisabled && isDisabled(params.row)) ||
(params.row.builtin &&
(requires.includes(PermissionAction.UPDATE) ||
requires.includes(PermissionAction.DELETE)))
}
onClick={() => {
action && action(params.row);
}}
/>
),
)}
</Stack>
);
}

View File

@@ -14,7 +14,6 @@ import {
DialogProps,
MenuItem,
} from "@mui/material";
import { isAbsoluteUrl } from "next/dist/shared/lib/utils";
import { useEffect, FC } from "react";
import { useForm, Controller } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -26,6 +25,7 @@ import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem";
import { Input } from "@/app-components/inputs/Input";
import { ToggleableInput } from "@/app-components/inputs/ToggleableInput";
import { IMenuItem, IMenuItemAttributes, MenuType } from "@/types/menu.types";
import { isAbsoluteUrl } from "@/utils/URL";
export type MenuDialogProps = DialogProps & {
open: boolean;

View File

@@ -0,0 +1,151 @@
/*
* 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 {
Dialog,
DialogActions,
DialogContent,
FormControlLabel,
Switch,
} from "@mui/material";
import { FC, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import DialogButtons from "@/app-components/buttons/DialogButtons";
import { DialogTitle } from "@/app-components/dialogs/DialogTitle";
import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer";
import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem";
import { Input } from "@/app-components/inputs/Input";
import { useCreate } from "@/hooks/crud/useCreate";
import { useUpdate } from "@/hooks/crud/useUpdate";
import { DialogControlProps } from "@/hooks/useDialog";
import { useToast } from "@/hooks/useToast";
import { EntityType } from "@/services/types";
import { ILanguage, ILanguageAttributes } from "@/types/language.types";
export type LanguageDialogProps = DialogControlProps<ILanguage>;
export const LanguageDialog: FC<LanguageDialogProps> = ({
open,
data,
closeDialog,
...rest
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const { mutateAsync: createLanguage } = useCreate(EntityType.LANGUAGE, {
onError: () => {
toast.error(t("message.internal_server_error"));
},
onSuccess() {
closeDialog();
toast.success(t("message.success_save"));
},
});
const { mutateAsync: updateLanguage } = useUpdate(EntityType.LANGUAGE, {
onError: () => {
toast.error(t("message.internal_server_error"));
},
onSuccess() {
closeDialog();
toast.success(t("message.success_save"));
},
});
const {
reset,
register,
formState: { errors },
handleSubmit,
control,
} = useForm<ILanguageAttributes>({
defaultValues: {
title: data?.title || "",
code: data?.code || "",
isRTL: data?.isRTL || false,
},
});
const validationRules = {
title: {
required: t("message.title_is_required"),
},
code: {
required: t("message.code_is_required"),
},
};
const onSubmitForm = async (params: ILanguageAttributes) => {
if (data) {
updateLanguage({ id: data.id, params });
} else {
createLanguage(params);
}
};
useEffect(() => {
if (open) reset();
}, [open, reset]);
useEffect(() => {
if (data) {
reset({
title: data.title,
code: data.code,
isRTL: data.isRTL,
});
} else {
reset();
}
}, [data, reset]);
return (
<Dialog open={open} fullWidth onClose={closeDialog} {...rest}>
<form onSubmit={handleSubmit(onSubmitForm)}>
<DialogTitle onClose={closeDialog}>
{data ? t("title.edit_label") : t("title.new_label")}
</DialogTitle>
<DialogContent>
<ContentContainer>
<ContentItem>
<Input
label={t("label.title")}
error={!!errors.title}
{...register("title", validationRules.title)}
helperText={errors.title ? errors.title.message : null}
multiline={true}
/>
</ContentItem>
<ContentItem>
<Input
label={t("label.code")}
error={!!errors.code}
{...register("code", validationRules.code)}
helperText={errors.code ? errors.code.message : null}
multiline={true}
/>
</ContentItem>
<ContentItem>
<Controller
name="isRTL"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch {...field} checked={field.value} />}
label={t("label.is_rtl")}
/>
)}
/>
</ContentItem>
</ContentContainer>
</DialogContent>
<DialogActions>
<DialogButtons closeDialog={closeDialog} />
</DialogActions>
</form>
</Dialog>
);
};

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 { Flag } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
import { Button, Grid, Paper } from "@mui/material";
import { GridColDef } from "@mui/x-data-grid";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "react-query";
import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog";
import { FilterTextfield } from "@/app-components/inputs/FilterTextfield";
import {
ActionColumnLabel,
useActionColumns,
} from "@/app-components/tables/columns/getColumns";
import { renderHeader } from "@/app-components/tables/columns/renderHeader";
import { DataGrid } from "@/app-components/tables/DataGrid";
import { isSameEntity } from "@/hooks/crud/helpers";
import { useDelete } from "@/hooks/crud/useDelete";
import { useFind } from "@/hooks/crud/useFind";
import { useUpdate } from "@/hooks/crud/useUpdate";
import { getDisplayDialogs, useDialog } from "@/hooks/useDialog";
import { useHasPermission } from "@/hooks/useHasPermission";
import { useSearch } from "@/hooks/useSearch";
import { useToast } from "@/hooks/useToast";
import { PageHeader } from "@/layout/content/PageHeader";
import { EntityType } from "@/services/types";
import { ILanguage } from "@/types/language.types";
import { PermissionAction } from "@/types/permission.types";
import { getDateTimeFormatter } from "@/utils/date";
import { LanguageDialog } from "./LanguageDialog";
export const Languages = () => {
const { t } = useTranslation();
const { toast } = useToast();
const addDialogCtl = useDialog<ILanguage>(false);
const editDialogCtl = useDialog<ILanguage>(false);
const deleteDialogCtl = useDialog<string>(false);
const queryClient = useQueryClient();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ILanguage>({
$or: ["title", "code"],
});
const { dataGridProps, refetch } = useFind(
{ entity: EntityType.LANGUAGE },
{
params: searchPayload,
},
);
const { mutateAsync: updateLanguage } = useUpdate(EntityType.LANGUAGE, {
onError: () => {
toast.error(t("message.internal_server_error"));
},
onSuccess() {
refetch();
toast.success(t("message.success_save"));
},
});
const { mutateAsync: deleteLanguage } = useDelete(EntityType.LANGUAGE, {
onError: () => {
toast.error(t("message.internal_server_error"));
},
onSuccess() {
queryClient.removeQueries({
predicate: ({ queryKey }) => {
const [_qType, qEntity] = queryKey;
return isSameEntity(qEntity, EntityType.NLP_SAMPLE);
},
});
deleteDialogCtl.closeDialog();
toast.success(t("message.item_delete_success"));
},
});
const toggleDefault = (row: ILanguage) => {
if (!row.isDefault) {
updateLanguage({
id: row.id,
params: {
isDefault: true,
},
});
}
};
const actionColumns = useActionColumns<ILanguage>(
EntityType.LANGUAGE,
[
{
label: ActionColumnLabel.Toggle,
action: (row) => toggleDefault(row),
requires: [PermissionAction.UPDATE],
getState: (row) => row.isDefault,
helperText: t("button.mark_as_default"),
},
{
label: ActionColumnLabel.Edit,
action: (row) => editDialogCtl.openDialog(row),
requires: [PermissionAction.UPDATE],
},
{
label: ActionColumnLabel.Delete,
action: (row) => deleteDialogCtl.openDialog(row.id),
requires: [PermissionAction.DELETE],
isDisabled: (row) => row.isDefault,
},
],
t("label.operations"),
);
const columns: GridColDef<ILanguage>[] = [
{ field: "id", headerName: "ID" },
{
flex: 2,
field: "title",
headerName: t("label.title"),
disableColumnMenu: true,
renderHeader,
headerAlign: "left",
},
{
flex: 1,
field: "code",
headerName: t("label.code"),
disableColumnMenu: true,
renderHeader,
headerAlign: "left",
},
{
flex: 1,
field: "isDefault",
headerName: t("label.is_default"),
disableColumnMenu: true,
renderHeader,
headerAlign: "left",
valueGetter: (value) => (value ? t("label.yes") : t("label.no")),
},
{
flex: 1,
field: "isRTL",
headerName: t("label.is_rtl"),
disableColumnMenu: true,
renderHeader,
headerAlign: "left",
valueGetter: (value) => (value ? t("label.yes") : t("label.no")),
},
{
minWidth: 140,
field: "createdAt",
headerName: t("label.createdAt"),
disableColumnMenu: true,
renderHeader,
resizable: false,
headerAlign: "left",
valueGetter: (params) =>
t("datetime.created_at", getDateTimeFormatter(params)),
},
{
minWidth: 140,
field: "updatedAt",
headerName: t("label.updatedAt"),
disableColumnMenu: true,
renderHeader,
resizable: false,
headerAlign: "left",
valueGetter: (params) =>
t("datetime.updated_at", getDateTimeFormatter(params)),
},
actionColumns,
];
return (
<Grid container gap={3} flexDirection="column">
<LanguageDialog {...getDisplayDialogs(addDialogCtl)} />
<LanguageDialog {...getDisplayDialogs(editDialogCtl)} />
<DeleteDialog
{...deleteDialogCtl}
callback={() => {
if (deleteDialogCtl?.data) deleteLanguage(deleteDialogCtl.data);
}}
/>
<PageHeader icon={Flag} title={t("title.languages")}>
<Grid
justifyContent="flex-end"
gap={1}
container
alignItems="center"
flexShrink={0}
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
</Grid>
{hasPermission(EntityType.LANGUAGE, PermissionAction.CREATE) ? (
<Grid item>
<Button
startIcon={<AddIcon />}
variant="contained"
sx={{ float: "right" }}
onClick={() => addDialogCtl.openDialog()}
>
{t("button.add")}
</Button>
</Grid>
) : null}
</Grid>
</PageHeader>
<Grid item xs={12}>
<Paper sx={{ padding: 2 }}>
<Grid>
<DataGrid columns={columns} {...dataGridProps} />
</Grid>
</Paper>
</Grid>
</Grid>
);
};

View File

@@ -17,6 +17,7 @@ import AttachmentInput from "@/app-components/attachment/AttachmentInput";
import { DialogTitle } from "@/app-components/dialogs/DialogTitle";
import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer";
import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem";
import { isSameEntity } from "@/hooks/crud/helpers";
import { useApiClient } from "@/hooks/useApiClient";
import { DialogControlProps } from "@/hooks/useDialog";
import { useToast } from "@/hooks/useToast";
@@ -40,11 +41,19 @@ export const NlpImportDialog: FC<NlpImportDialogProps> = ({
attachmentId && (await apiClient.importNlpSamples(attachmentId));
},
onSuccess: () => {
queryClient.removeQueries([
QueryType.collection,
EntityType.NLP_SAMPLE,
]);
queryClient.removeQueries({
predicate: ({ queryKey }) => {
const [qType, qEntity] = queryKey;
return (
((qType === QueryType.count || qType === QueryType.collection) &&
isSameEntity(qEntity, EntityType.NLP_SAMPLE)) ||
isSameEntity(qEntity, EntityType.NLP_SAMPLE_ENTITY) ||
isSameEntity(qEntity, EntityType.NLP_ENTITY) ||
isSameEntity(qEntity, EntityType.NLP_VALUE)
);
},
});
handleCloseDialog();
toast.success(t("message.success_save"));
},

View File

@@ -17,14 +17,14 @@ import { DialogControlProps } from "@/hooks/useDialog";
import { useToast } from "@/hooks/useToast";
import { EntityType } from "@/services/types";
import {
INlpDatasetSample,
INlpDatasetSampleAttributes,
INlpSampleFormAttributes,
INlpSampleFull,
} from "@/types/nlp-sample.types";
import NlpDatasetSample from "./components/NlpTrainForm";
export type NlpSampleDialogProps = DialogControlProps<INlpSampleFull>;
export type NlpSampleDialogProps = DialogControlProps<INlpDatasetSample>;
export const NlpSampleDialog: FC<NlpSampleDialogProps> = ({
open,
data: sample,
@@ -44,15 +44,16 @@ export const NlpSampleDialog: FC<NlpSampleDialogProps> = ({
toast.success(t("message.success_save"));
},
});
const onSubmitForm = (params: INlpSampleFormAttributes) => {
const onSubmitForm = (form: INlpSampleFormAttributes) => {
if (sample?.id) {
updateSample(
{
id: sample.id,
params: {
text: params.text,
type: params.type,
entities: [...params.keywordEntities, ...params.traitEntities],
text: form.text,
type: form.type,
entities: [...form.keywordEntities, ...form.traitEntities],
language: form.language,
},
},
{

View File

@@ -20,10 +20,11 @@ import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem";
import { Input } from "@/app-components/inputs/Input";
import MultipleInput from "@/app-components/inputs/MultipleInput";
import { useCreate } from "@/hooks/crud/useCreate";
import { useGet } from "@/hooks/crud/useGet";
import { useUpdate } from "@/hooks/crud/useUpdate";
import { DialogControlProps } from "@/hooks/useDialog";
import { useToast } from "@/hooks/useToast";
import { EntityType } from "@/services/types";
import { EntityType, Format } from "@/services/types";
import { INlpValue, INlpValueAttributes } from "@/types/nlp-value.types";
export type TNlpValueAttributesWithRequiredExpressions = INlpValueAttributes & {
@@ -44,11 +45,16 @@ export const NlpValueDialog: FC<NlpValueDialogProps> = ({
const { t } = useTranslation();
const { toast } = useToast();
const { query } = useRouter();
const { refetch: refetchEntity } = useGet(data?.entity || String(query.id), {
entity: EntityType.NLP_ENTITY,
format: Format.FULL,
});
const { mutateAsync: createNlpValue } = useCreate(EntityType.NLP_VALUE, {
onError: () => {
toast.error(t("message.internal_server_error"));
},
onSuccess(data) {
refetchEntity();
closeDialog();
toast.success(t("message.success_save"));
callback?.(data);

View File

@@ -19,6 +19,7 @@ import {
Grid,
IconButton,
MenuItem,
Stack,
} from "@mui/material";
import { GridColDef } from "@mui/x-data-grid";
import { useState } from "react";
@@ -26,6 +27,7 @@ import { useTranslation } from "react-i18next";
import { DeleteDialog } from "@/app-components/dialogs";
import { ChipEntity } from "@/app-components/displays/ChipEntity";
import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect";
import { FilterTextfield } from "@/app-components/inputs/FilterTextfield";
import { Input } from "@/app-components/inputs/Input";
import {
@@ -43,9 +45,10 @@ import { useHasPermission } from "@/hooks/useHasPermission";
import { useSearch } from "@/hooks/useSearch";
import { useToast } from "@/hooks/useToast";
import { EntityType, Format } from "@/services/types";
import { ILanguage } from "@/types/language.types";
import {
INlpDatasetSample,
INlpSample,
INlpSampleFull,
NlpSampleType,
} from "@/types/nlp-sample.types";
import { INlpSampleEntity } from "@/types/nlp-sample_entity.types";
@@ -66,12 +69,17 @@ export default function NlpSample() {
const { apiUrl } = useConfig();
const { toast } = useToast();
const { t } = useTranslation();
const [dataset, setDataSet] = useState("");
const [type, setType] = useState<NlpSampleType | undefined>(undefined);
const [language, setLanguage] = useState<string | undefined>(undefined);
const hasPermission = useHasPermission();
const getNlpEntityFromCache = useGetFromCache(EntityType.NLP_ENTITY);
const getNlpValueFromCache = useGetFromCache(EntityType.NLP_VALUE);
const getSampleEntityFromCache = useGetFromCache(
EntityType.NLP_SAMPLE_ENTITY,
);
const getLanguageFromCache = useGetFromCache(EntityType.LANGUAGE);
const { onSearch, searchPayload } = useSearch<INlpSample>({
$eq: dataset === "" ? [] : [{ type: dataset as NlpSampleType }],
$eq: [...(type ? [{ type }] : []), ...(language ? [{ language }] : [])],
$iLike: ["text"],
});
const { mutateAsync: deleteNlpSample } = useDelete(EntityType.NLP_SAMPLE, {
@@ -90,21 +98,30 @@ export default function NlpSample() {
},
);
const deleteDialogCtl = useDialog<string>(false);
const editDialogCtl = useDialog<INlpSampleFull>(false);
const editDialogCtl = useDialog<INlpDatasetSample>(false);
const importDialogCtl = useDialog<never>(false);
const actionColumns = getActionsColumn<INlpSampleFull>(
const actionColumns = getActionsColumn<INlpSample>(
[
{
label: ActionColumnLabel.Edit,
action: ({ entities, ...rest }) => {
const data: INlpSampleFull = {
action: ({ entities, language, ...rest }) => {
const data: INlpDatasetSample = {
...rest,
entities: entities?.map(({ end, start, value, entity }) => ({
end,
start,
value: getNlpValueFromCache(value)?.value,
entity: getNlpEntityFromCache(entity)?.name,
})) as unknown as INlpSampleEntity[],
entities: entities?.map((e) => {
const sampleEntity = getSampleEntityFromCache(e);
const { end, start, value, entity } =
sampleEntity as INlpSampleEntity;
return {
end,
start,
value: getNlpValueFromCache(value)?.value || "",
entity: getNlpEntityFromCache(entity)?.name || "",
};
}),
language: language
? (getLanguageFromCache(language) as ILanguage).code
: null,
};
editDialogCtl.openDialog(data);
@@ -119,7 +136,7 @@ export default function NlpSample() {
],
t("label.operations"),
);
const columns: GridColDef<INlpSampleFull>[] = [
const columns: GridColDef<INlpSample>[] = [
{
flex: 1,
field: "text",
@@ -131,39 +148,56 @@ export default function NlpSample() {
{
flex: 1,
field: "entities",
renderCell: ({ row }) =>
row.entities.map((entity) => (
<ChipEntity
id={entity.entity}
key={entity.id}
variant="title"
field="name"
render={(value) => (
<Chip
renderCell: ({ row }) => (
<Stack direction="row" my={1} spacing={1}>
{row.entities
.map((e) => getSampleEntityFromCache(e) as INlpSampleEntity)
.filter((e) => !!e)
.map((entity) => (
<ChipEntity
key={entity.id}
id={entity.entity}
variant="title"
label={
<>
{value}
{` `}={` `}
<ChipEntity
id={entity.value}
key={entity.value}
variant="text"
field="value"
entity={EntityType.NLP_VALUE}
/>
</>
}
field="name"
render={(value) => (
<Chip
variant="title"
label={
<>
{value}
{` `}={` `}
<ChipEntity
id={entity.value}
key={entity.value}
variant="text"
field="value"
entity={EntityType.NLP_VALUE}
/>
</>
}
/>
)}
entity={EntityType.NLP_ENTITY}
/>
)}
entity={EntityType.NLP_ENTITY}
/>
)),
))}
</Stack>
),
headerName: t("label.entities"),
sortable: false,
disableColumnMenu: true,
renderHeader,
},
{
maxWidth: 90,
field: "language",
renderCell: ({ row }) => {
return row.language ? getLanguageFromCache(row.language)?.title : "";
},
headerName: t("label.language"),
sortable: true,
disableColumnMenu: true,
renderHeader,
},
{
maxWidth: 90,
field: "type",
@@ -232,18 +266,33 @@ export default function NlpSample() {
fullWidth={false}
sx={{ minWidth: "256px" }}
/>
<AutoCompleteEntitySelect<ILanguage, "title", false>
fullWidth={false}
sx={{
minWidth: "150px",
}}
autoFocus
searchFields={["title", "code"]}
entity={EntityType.LANGUAGE}
format={Format.BASIC}
labelKey="title"
label={t("label.language")}
multiple={false}
onChange={(_e, selected) => setLanguage(selected?.id)}
/>
<Input
select
fullWidth={false}
sx={{
width: "150px",
minWidth: "150px",
}}
label={t("label.dataset")}
value={dataset}
onChange={(e) => setDataSet(e.target.value)}
value={type}
onChange={(e) => setType(e.target.value as NlpSampleType)}
SelectProps={{
...(dataset !== "" && {
...(type && {
IconComponent: () => (
<IconButton size="small" onClick={() => setDataSet("")}>
<IconButton size="small" onClick={() => setType(undefined)}>
<DeleteIcon />
</IconButton>
),
@@ -288,7 +337,7 @@ export default function NlpSample() {
variant="contained"
href={buildURL(
apiUrl,
`nlpsample/export${dataset ? `?type=${dataset}` : ""}`,
`nlpsample/export${type ? `?type=${type}` : ""}`,
)}
startIcon={<DownloadIcon />}
>

View File

@@ -23,7 +23,7 @@ import {
RadioGroup,
Typography,
} from "@mui/material";
import { FC, useCallback, useMemo, useState } from "react";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useQuery } from "react-query";
@@ -36,18 +36,19 @@ import { useFind } from "@/hooks/crud/useFind";
import { useGetFromCache } from "@/hooks/crud/useGet";
import { useApiClient } from "@/hooks/useApiClient";
import { EntityType, Format } from "@/services/types";
import { ILanguage } from "@/types/language.types";
import { INlpEntity } from "@/types/nlp-entity.types";
import {
INlpDatasetKeywordEntity,
INlpDatasetSample,
INlpDatasetTraitEntity,
INlpSampleFormAttributes,
INlpSampleFull,
NlpSampleType,
} from "@/types/nlp-sample.types";
import { INlpValue } from "@/types/nlp-value.types";
type NlpDatasetSampleProps = {
sample?: INlpSampleFull;
sample?: INlpDatasetSample;
submitForm: (params: INlpSampleFormAttributes) => void;
};
@@ -64,68 +65,40 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
{
hasCount: false,
},
{
onSuccess(entities) {
// By default append trait entities
if (!sample) {
removeTraitEntity();
(entities || [])
.filter(({ lookups }) => lookups.includes("trait"))
.forEach(({ name }) => {
appendTraitEntity({
entity: name,
value: "",
});
});
}
},
},
);
const getNlpValueFromCache = useGetFromCache(EntityType.NLP_VALUE);
// Default trait entities to append to the form
const defaultTraitEntities = useMemo(() => {
if (!sample || !entities) return [];
const traitEntities = entities.filter(({ lookups }) =>
lookups.includes("trait"),
);
const sampleTraitEntities = sample.entities.filter(
(e) => typeof e.start === "undefined",
);
if (sampleTraitEntities.length === traitEntities.length) {
return sampleTraitEntities;
}
const sampleEntityNames = new Set(sampleTraitEntities.map((e) => e.entity));
const missingEntities = traitEntities
.filter(({ name }) => !sampleEntityNames.has(name))
.map(({ name }) => ({
entity: name,
value: "",
}));
return [...sampleTraitEntities, ...missingEntities];
}, [entities, sample]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const defaultValues: INlpSampleFormAttributes = useMemo(
() => ({
type: sample?.type || NlpSampleType.train,
text: sample?.text || "",
language: sample?.language || null,
traitEntities: (entities || [])
.filter(({ lookups }) => {
return lookups.includes("trait");
})
.map((e) => {
return {
entity: e.name,
value: sample
? sample.entities.find(({ entity }) => entity === e.name)?.value
: "",
} as INlpDatasetTraitEntity;
}),
keywordEntities: (sample?.entities || []).filter(
(e) => "start" in e && typeof e.start === "number",
) as INlpDatasetKeywordEntity[],
}),
[sample, entities],
);
const { handleSubmit, control, register, reset, setValue, watch } =
useForm<INlpSampleFormAttributes>({
defaultValues: {
type: sample?.type || NlpSampleType.train,
text: sample?.text || "",
traitEntities: defaultTraitEntities,
keywordEntities:
sample?.entities.filter((e) => typeof e.start === "number") || [],
},
defaultValues,
});
const currentText = watch("text");
const currentType = watch("type");
const { apiClient } = useApiClient();
const {
fields: traitEntities,
append: appendTraitEntity,
update: updateTraitEntity,
remove: removeTraitEntity,
} = useFieldArray({
const { fields: traitEntities, update: updateTraitEntity } = useFieldArray({
control,
name: "traitEntities",
});
@@ -153,12 +126,16 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
},
onSuccess: (result) => {
const traitEntities: INlpDatasetTraitEntity[] = result.entities.filter(
(e) => !("start" in e && "end" in e),
(e) => !("start" in e && "end" in e) && e.entity !== "language",
);
const keywordEntities = result.entities.filter(
(e) => "start" in e && "end" in e,
) as INlpDatasetKeywordEntity[];
const language = result.entities.find(
({ entity }) => entity === "language",
);
setValue("language", language?.value || "");
setValue("traitEntities", traitEntities);
setValue("keywordEntities", keywordEntities);
},
@@ -167,7 +144,7 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
const findInsertIndex = (newItem: INlpDatasetKeywordEntity): number => {
const index = keywordEntities.findIndex(
(entity) => entity.start > newItem.start,
(entity) => entity.start && newItem.start && entity.start > newItem.start,
);
return index === -1 ? keywordEntities.length : index;
@@ -177,14 +154,20 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
start: number;
end: number;
} | null>(null);
const onSubmitForm = (params: INlpSampleFormAttributes) => {
submitForm(params);
reset();
removeTraitEntity();
removeKeywordEntity();
const onSubmitForm = (form: INlpSampleFormAttributes) => {
submitForm(form);
refetchEntities();
reset({
...defaultValues,
text: "",
});
};
useEffect(() => {
reset(defaultValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(defaultValues)]);
return (
<Box className="nlp-train" sx={{ position: "relative", p: 2 }}>
<form onSubmit={handleSubmit(onSubmitForm)}>
@@ -247,6 +230,39 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
/>
</ContentItem>
<Box display="flex" flexDirection="column">
<ContentItem
display="flex"
flexDirection="row"
maxWidth="50%"
gap={2}
>
<Controller
name="language"
control={control}
render={({ field }) => {
const { onChange, ...rest } = field;
return (
<AutoCompleteEntitySelect<ILanguage, "title", false>
fullWidth={true}
autoFocus
searchFields={["title", "code"]}
entity={EntityType.LANGUAGE}
format={Format.BASIC}
labelKey="title"
idKey="code"
label={t("label.language")}
multiple={false}
{...field}
onChange={(_e, selected) => {
onChange(selected?.code);
}}
{...rest}
/>
);
}}
/>
</ContentItem>
{traitEntities.map((traitEntity, index) => (
<ContentItem
key={traitEntity.id}

View File

@@ -142,8 +142,6 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
return (
<Grid container gap={2} flexDirection="column">
{/* <PageHeader title={t("title.nlp_train")} icon={faGraduationCap} /> */}
<Slide direction={direction} in={true} mountOnEnter unmountOnExit>
<Grid item xs={12}>
<Box sx={{ padding: 1 }}>

View File

@@ -81,6 +81,7 @@ export const Nlp = ({
text: params.text,
type: params.type,
entities: [...params.traitEntities, ...params.keywordEntities],
language: params.language,
});
};

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