diff --git a/api/src/app.module.ts b/api/src/app.module.ts index aa5c8619..8025a47a 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -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, diff --git a/api/src/chat/controllers/block.controller.spec.ts b/api/src/chat/controllers/block.controller.spec.ts index 08ad35f0..36580faf 100644 --- a/api/src/chat/controllers/block.controller.spec.ts +++ b/api/src/chat/controllers/block.controller.spec.ts @@ -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 { 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,6 +110,7 @@ describe('BlockController', () => { UserService, RoleService, PermissionService, + LanguageService, PluginService, LoggerService, { diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 7ef87867..3f2104e1 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -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'; @@ -28,7 +29,10 @@ 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'; @@ -92,6 +96,7 @@ describe('BlockService', () => { ContentModel, AttachmentModel, LabelModel, + LanguageModel, ]), ], providers: [ @@ -100,11 +105,13 @@ describe('BlockService', () => { ContentTypeRepository, ContentRepository, AttachmentRepository, + LanguageRepository, BlockService, CategoryService, ContentTypeService, ContentService, AttachmentService, + LanguageService, { provide: PluginService, useValue: {}, @@ -130,6 +137,14 @@ describe('BlockService', () => { }, }, EventEmitter2, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }, ], }).compile(); blockService = module.get(BlockService); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 8104f4f5..5f228e2a 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -14,6 +14,7 @@ import { AttachmentService } from '@/attachment/services/attachment.service'; import EventWrapper from '@/channel/lib/EventWrapper'; import { ContentService } from '@/cms/services/content.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,6 +45,7 @@ export class BlockService extends BaseService { private readonly pluginService: PluginService, private readonly logger: LoggerService, protected readonly i18n: I18nService, + protected readonly languageService: LanguageService, ) { super(repository); } @@ -108,12 +110,9 @@ export class BlockService extends BaseService { // 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); @@ -369,12 +368,11 @@ export class BlockService extends BaseService { * @returns The text message translated and tokens being replaces with values */ processText(text: string, context: Context, 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, context, settings); return text; diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 3b51ff4e..2c23d13e 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -120,10 +120,6 @@ export const config: Config = { limit: 10, }, chatbot: { - lang: { - default: 'en', - available: ['en', 'fr'], - }, messages: { track_delivery: false, track_read: false, diff --git a/api/src/config/types.ts b/api/src/config/types.ts index 971a1be4..0d810362 100644 --- a/api/src/config/types.ts +++ b/api/src/config/types.ts @@ -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; diff --git a/api/src/extensions/channels/offline/index.channel.ts b/api/src/extensions/channels/offline/index.channel.ts index 001a8d6a..0ecf5c75 100644 --- a/api/src/extensions/channels/offline/index.channel.ts +++ b/api/src/extensions/channels/offline/index.channel.ts @@ -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', diff --git a/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts b/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts index ca9dd05c..9433b405 100644 --- a/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts +++ b/api/src/extensions/helpers/nlp/default/__test__/index.spec.ts @@ -87,8 +87,6 @@ describe('NLP Default Helper', () => { provider: 'default', endpoint: 'path', token: 'token', - languages: ['fr', 'ar', 'tn'], - default_lang: 'fr', threshold: '0.5', }, })), diff --git a/api/src/i18n/controllers/translation.controller.spec.ts b/api/src/i18n/controllers/translation.controller.spec.ts index bba1c045..6854c770 100644 --- a/api/src/i18n/controllers/translation.controller.spec.ts +++ b/api/src/i18n/controllers/translation.controller.spec.ts @@ -51,8 +51,11 @@ import { import { TranslationController } from './translation.controller'; import { TranslationUpdateDto } from '../dto/translation.dto'; +import { LanguageRepository } from '../repositories/language.repository'; import { TranslationRepository } from '../repositories/translation.repository'; +import { LanguageModel } from '../schemas/language.schema'; import { Translation, TranslationModel } from '../schemas/translation.schema'; +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: [ @@ -117,7 +121,7 @@ describe('TranslationController', () => { 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); diff --git a/api/src/i18n/controllers/translation.controller.ts b/api/src/i18n/controllers/translation.controller.ts index 6db99836..9c966709 100644 --- a/api/src/i18n/controllers/translation.controller.ts +++ b/api/src/i18n/controllers/translation.controller.ts @@ -14,9 +14,9 @@ import { NotFoundException, Param, Patch, + Post, Query, UseInterceptors, - Post, } from '@nestjs/common'; import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { TFilterQuery } from 'mongoose'; @@ -31,12 +31,14 @@ 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 { constructor( + private readonly languageService: LanguageService, private readonly translationService: TranslationService, private readonly settingService: SettingService, private readonly logger: LoggerService, @@ -103,40 +105,37 @@ export class TranslationController extends BaseController { @CsrfCheck(true) @Post('refresh') async refresh(): Promise { - 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 }, + }); } } diff --git a/api/src/i18n/dto/language.dto.ts b/api/src/i18n/dto/language.dto.ts index d9deb5d7..4fe52a61 100644 --- a/api/src/i18n/dto/language.dto.ts +++ b/api/src/i18n/dto/language.dto.ts @@ -9,7 +9,7 @@ import { PartialType } from '@nestjs/mapped-types'; import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class LanguageCreateDto { @ApiProperty({ description: 'Language Title', type: String }) @@ -22,15 +22,14 @@ export class LanguageCreateDto { @IsString() code: string; - @ApiProperty({ description: 'Is Default Language ?', type: Boolean }) - @IsNotEmpty() - @IsBoolean() - isDefault: boolean; - @ApiProperty({ description: 'Whether Language is RTL', type: Boolean }) - @IsNotEmpty() @IsBoolean() - isRTL?: boolean; + isRTL: boolean; } -export class LanguageUpdateDto extends PartialType(LanguageCreateDto) {} +export class LanguageUpdateDto extends PartialType(LanguageCreateDto) { + @ApiProperty({ description: 'Is Default Language ?', type: Boolean }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} diff --git a/api/src/i18n/schemas/language.schema.ts b/api/src/i18n/schemas/language.schema.ts index ea829a6e..e1264b44 100644 --- a/api/src/i18n/schemas/language.schema.ts +++ b/api/src/i18n/schemas/language.schema.ts @@ -30,8 +30,9 @@ export class Language extends BaseSchema { @Prop({ type: Boolean, + default: false, }) - isDefault: boolean; + isDefault?: boolean; @Prop({ type: Boolean, diff --git a/api/src/i18n/schemas/translation.schema.ts b/api/src/i18n/schemas/translation.schema.ts index 55cd9e73..12c80c59 100644 --- a/api/src/i18n/schemas/translation.schema.ts +++ b/api/src/i18n/schemas/translation.schema.ts @@ -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; - - @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; diff --git a/api/src/i18n/seeds/language.seed-model.ts b/api/src/i18n/seeds/language.seed-model.ts index 30d72c84..82d678d2 100644 --- a/api/src/i18n/seeds/language.seed-model.ts +++ b/api/src/i18n/seeds/language.seed-model.ts @@ -13,11 +13,11 @@ export const languageModels: LanguageCreateDto[] = [ { title: 'English', code: 'en', - isDefault: true, + isRTL: false, }, { title: 'Français', code: 'fr', - isDefault: false, + isRTL: false, }, ]; diff --git a/api/src/i18n/services/i18n.service.ts b/api/src/i18n/services/i18n.service.ts index 60a5568e..3463035f 100644 --- a/api/src/i18n/services/i18n.service.ts +++ b/api/src/i18n/services/i18n.service.ts @@ -8,7 +8,6 @@ */ import { Injectable } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; import { I18nService as NativeI18nService, Path, @@ -24,11 +23,7 @@ import { Translation } from '@/i18n/schemas/translation.schema'; export class I18nService< K = Record, > extends NativeI18nService { - private dynamicTranslations: Record> = - config.chatbot.lang.available.reduce( - (acc, curr) => ({ ...acc, [curr]: {} }), - {}, - ); + private dynamicTranslations: Record> = {}; t

= any, R = PathValue>( key: P, @@ -40,17 +35,19 @@ export class I18nService< ...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; } } @@ -59,15 +56,13 @@ export class I18nService< return super.t(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); diff --git a/api/src/i18n/services/translation.service.ts b/api/src/i18n/services/translation.service.ts index 924de406..e632775a 100644 --- a/api/src/i18n/services/translation.service.ts +++ b/api/src/i18n/services/translation.service.ts @@ -33,7 +33,7 @@ export class TranslationService extends BaseService { public async resetI18nTranslations() { const translations = await this.findAll(); - this.i18n.initDynamicTranslations(translations); + this.i18n.refreshDynamicTranslations(translations); } /** diff --git a/api/src/setting/repositories/setting.repository.ts b/api/src/setting/repositories/setting.repository.ts index 6c1d3625..018c9636 100644 --- a/api/src/setting/repositories/setting.repository.ts +++ b/api/src/setting/repositories/setting.repository.ts @@ -12,7 +12,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectModel } from '@nestjs/mongoose'; import { Document, Model, Query, Types } from 'mongoose'; -import { config } from '@/config'; import { I18nService } from '@/i18n/services/i18n.service'; import { BaseRepository } from '@/utils/generics/base-repository'; @@ -65,8 +64,7 @@ export class SettingRepository extends BaseRepository { * 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 { '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 & - 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; - } - } } } diff --git a/api/src/setting/schemas/types.ts b/api/src/setting/schemas/types.ts index f13f849e..cadf2be7 100644 --- a/api/src/setting/schemas/types.ts +++ b/api/src/setting/schemas/types.ts @@ -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; diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index 855aafa6..2f28674a 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -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', diff --git a/api/src/user/controllers/auth.controller.spec.ts b/api/src/user/controllers/auth.controller.spec.ts index e888bb26..e5a13fc8 100644 --- a/api/src/user/controllers/auth.controller.spec.ts +++ b/api/src/user/controllers/auth.controller.spec.ts @@ -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 { 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, diff --git a/api/src/user/controllers/user.controller.spec.ts b/api/src/user/controllers/user.controller.spec.ts index 7aee9828..a674dd9f 100644 --- a/api/src/user/controllers/user.controller.spec.ts +++ b/api/src/user/controllers/user.controller.spec.ts @@ -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 { 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,6 +112,8 @@ describe('UserController', () => { }, AttachmentService, AttachmentRepository, + LanguageService, + LanguageRepository, ValidateAccountService, { provide: I18nService, diff --git a/api/src/user/services/invitation.service.spec.ts b/api/src/user/services/invitation.service.spec.ts index 90fb79f5..bb5e7ac0 100644 --- a/api/src/user/services/invitation.service.spec.ts +++ b/api/src/user/services/invitation.service.spec.ts @@ -16,7 +16,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; import { SentMessageInfo } from 'nodemailer'; +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,6 +70,8 @@ describe('InvitationService', () => { PermissionRepository, InvitationRepository, InvitationService, + LanguageRepository, + LanguageService, JwtService, Logger, { diff --git a/api/src/user/services/invitation.service.ts b/api/src/user/services/invitation.service.ts index 925eb5e5..a5f928d6 100644 --- a/api/src/user/services/invitation.service.ts +++ b/api/src/user/services/invitation.service.ts @@ -18,6 +18,7 @@ import { MailerService } from '@nestjs-modules/mailer'; import { config } from '@/config'; 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'; @@ -42,6 +43,7 @@ export class InvitationService extends BaseService< @Optional() private readonly mailerService: MailerService | undefined, private logger: LoggerService, 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'), }); diff --git a/api/src/user/services/passwordReset.service.spec.ts b/api/src/user/services/passwordReset.service.spec.ts index cbbfc957..e2c84479 100644 --- a/api/src/user/services/passwordReset.service.spec.ts +++ b/api/src/user/services/passwordReset.service.spec.ts @@ -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 { 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, diff --git a/api/src/user/services/passwordReset.service.ts b/api/src/user/services/passwordReset.service.ts index 9115914e..f3ded11b 100644 --- a/api/src/user/services/passwordReset.service.ts +++ b/api/src/user/services/passwordReset.service.ts @@ -22,6 +22,7 @@ import { compareSync } from 'bcryptjs'; import { config } from '@/config'; 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'; @@ -35,6 +36,7 @@ export class PasswordResetService { private logger: LoggerService, private readonly userService: UserService, 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'), }); diff --git a/api/src/user/services/validate-account.service.spec.ts b/api/src/user/services/validate-account.service.spec.ts index adb18a4c..5d72f831 100644 --- a/api/src/user/services/validate-account.service.spec.ts +++ b/api/src/user/services/validate-account.service.spec.ts @@ -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 { 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, @@ -74,6 +81,14 @@ describe('ValidateAccountService', () => { t: jest.fn().mockImplementation((t) => t), }, }, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }, ], }).compile(); validateAccountService = module.get( diff --git a/api/src/user/services/validate-account.service.ts b/api/src/user/services/validate-account.service.ts index 9526066a..ccaa0dcd 100644 --- a/api/src/user/services/validate-account.service.ts +++ b/api/src/user/services/validate-account.service.ts @@ -19,6 +19,8 @@ import { MailerService } from '@nestjs-modules/mailer'; import { config } from '@/config'; 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 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'); + } } } diff --git a/frontend/src/components/nlp/NlpImportDialog.tsx b/frontend/src/components/nlp/NlpImportDialog.tsx index 0be43dd8..d62fc400 100644 --- a/frontend/src/components/nlp/NlpImportDialog.tsx +++ b/frontend/src/components/nlp/NlpImportDialog.tsx @@ -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,12 +41,19 @@ export const NlpImportDialog: FC = ({ attachmentId && (await apiClient.importNlpSamples(attachmentId)); }, onSuccess: () => { - queryClient.removeQueries([ - QueryType.collection, - EntityType.NLP_SAMPLE, - ]); - queryClient.removeQueries([QueryType.count, 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")); }, diff --git a/frontend/src/components/nlp/components/NlpSample.tsx b/frontend/src/components/nlp/components/NlpSample.tsx index 7bfe8a19..4bdad053 100644 --- a/frontend/src/components/nlp/components/NlpSample.tsx +++ b/frontend/src/components/nlp/components/NlpSample.tsx @@ -149,6 +149,7 @@ export default function NlpSample() { renderCell: ({ row }) => row.entities .map((e) => getSampleEntityFromCache(e) as INlpSampleEntity) + .filter((e) => !!e) .map((entity) => ( = ({ closeDialog, ...rest }) => { + const { data: languages } = useFind( + { entity: EntityType.LANGUAGE }, + { + hasCount: false, + }, + ); const { t } = useTranslation(); const { toast } = useToast(); - const availableLanguages = useSetting("nlp_settings", "languages"); - const defaultLanguage = useSetting("nlp_settings", "default_lang"); const { mutateAsync: updateTranslation } = useUpdate(EntityType.TRANSLATION, { onError: () => { toast.error(t("message.internal_server_error")); @@ -49,29 +59,16 @@ export const EditTranslationDialog: FC = ({ toast.success(t("message.success_save")); }, }); - const defaultValues: ITranslation | undefined = useMemo( - () => - data - ? { - ...data, - translations: { - ...data?.translations, - [defaultLanguage]: data?.str, - }, - } - : undefined, - [defaultLanguage, data], - ); const { reset, control, handleSubmit } = useForm({ - defaultValues, + defaultValues: data, }); const onSubmitForm = async (params: ITranslationAttributes) => { if (data?.id) updateTranslation({ id: data.id, params }); }; useEffect(() => { - if (open) reset(defaultValues); - }, [open, reset, defaultValues]); + if (open) reset(data); + }, [open, reset, data]); return (

@@ -80,21 +77,26 @@ export const EditTranslationDialog: FC = ({ {t("title.update_translation")} + + {t("label.original_text")} + {data?.str} + - {availableLanguages?.map((language: string) => ( - - ( - - )} - /> - - ))} + {languages + .filter(({ isDefault }) => !isDefault) + .map((language) => ( + + ( + + )} + /> + + ))} diff --git a/frontend/src/components/translations/TranslationInput.tsx b/frontend/src/components/translations/TranslationInput.tsx index 73db4a1e..abad2781 100644 --- a/frontend/src/components/translations/TranslationInput.tsx +++ b/frontend/src/components/translations/TranslationInput.tsx @@ -7,24 +7,16 @@ * 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 CheckIcon from "@mui/icons-material/Check"; -import CloseIcon from "@mui/icons-material/Close"; import { Grid } from "@mui/material"; import React from "react"; import { ControllerRenderProps } from "react-hook-form"; import { Input } from "@/app-components/inputs/Input"; -import { - ITranslationAttributes, - ITranslations, -} from "@/types/translation.types"; - -const isRTL = (language: string) => { - return ["AR"].includes(language.toUpperCase()); -}; +import { ILanguage } from "@/types/language.types"; +import { ITranslationAttributes } from "@/types/translation.types"; interface RenderTranslationInputProps { - language: keyof ITranslations; + language: ILanguage; field: ControllerRenderProps; } @@ -34,14 +26,14 @@ const TranslationInput: React.FC = ({ }) => ( - {language.toUpperCase()} - {field.value ? : } + {language.title} } multiline={true} + minRows={3} {...field} /> ); diff --git a/frontend/src/components/translations/index.tsx b/frontend/src/components/translations/index.tsx index a078b5ef..5dcca2f0 100644 --- a/frontend/src/components/translations/index.tsx +++ b/frontend/src/components/translations/index.tsx @@ -9,7 +9,7 @@ import { faLanguage } from "@fortawesome/free-solid-svg-icons"; import AutorenewIcon from "@mui/icons-material/Autorenew"; -import { Button, Chip, Grid, Paper } from "@mui/material"; +import { Button, Chip, Grid, Paper, Stack } from "@mui/material"; import { GridColDef } from "@mui/x-data-grid"; import { useTranslation } from "react-i18next"; @@ -25,10 +25,10 @@ import { useFind } from "@/hooks/crud/useFind"; import { useRefreshTranslations } from "@/hooks/entities/translation-hooks"; import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useSearch } from "@/hooks/useSearch"; -import { useSetting } from "@/hooks/useSetting"; 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 { ITranslation } from "@/types/translation.types"; import { getDateTimeFormatter } from "@/utils/date"; @@ -38,7 +38,12 @@ import { EditTranslationDialog } from "./EditTranslationDialog"; export const Translations = () => { const { t } = useTranslation(); const { toast } = useToast(); - const availableLanguages = useSetting("nlp_settings", "languages"); + const { data: languages } = useFind( + { entity: EntityType.LANGUAGE }, + { + hasCount: false, + }, + ); const editDialogCtl = useDialog(false); const deleteDialogCtl = useDialog(false); const { onSearch, searchPayload } = useSearch({ @@ -92,22 +97,23 @@ export const Translations = () => { field: "translations", headerName: t("label.translations"), sortable: false, - renderCell: (params) => - availableLanguages.map((language: string) => ( - - )), - }, - { - maxWidth: 127, - field: "translated", - resizable: false, - headerName: t("label.translated"), + renderCell: (params) => ( + + {languages + .filter(({ isDefault }) => !isDefault) + .map((language: ILanguage) => ( + + ))} + + ), }, { maxWidth: 140, @@ -167,7 +173,6 @@ export const Translations = () => { deleteTranslation(deleteDialogCtl.data); }} /> - diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx index 38eb1ea2..1b11e2e5 100644 --- a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx +++ b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"; import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect"; import { Input } from "@/app-components/inputs/Input"; import { RegexInput } from "@/app-components/inputs/RegexInput"; +import { useGetFromCache } from "@/hooks/crud/useGet"; import { EntityType, Format } from "@/services/types"; import { IBlockAttributes, @@ -25,7 +26,8 @@ import { PayloadPattern, } from "@/types/block.types"; import { IMenuItem } from "@/types/menu.types"; -import { INlpValueFull } from "@/types/nlp-value.types"; +import { INlpEntity } from "@/types/nlp-entity.types"; +import { INlpValue } from "@/types/nlp-value.types"; import { ContentPostbackInput } from "./ContentPostbackInput"; import { PostbackInput } from "./PostbackInput"; @@ -64,6 +66,7 @@ const PatternInput: FC = ({ value, onChange, idx }) => { register, formState: { errors }, } = useFormContext(); + const getNlpEntityFromCache = useGetFromCache(EntityType.NLP_ENTITY); const [pattern, setPattern] = useState(value); const [patternType, setPatternType] = useState(getType(value)); const types = [ @@ -140,7 +143,7 @@ const PatternInput: FC = ({ value, onChange, idx }) => { {patternType === "nlp" ? ( - + value={(pattern as NlpPattern[]).map((v) => "value" in v && v.value ? v.value : v.entity, )} @@ -153,25 +156,31 @@ const PatternInput: FC = ({ value, onChange, idx }) => { multiple={true} onChange={(_e, data) => { setPattern( - data.map((d) => - d.value === "any" + data.map((d) => { + const entity = getNlpEntityFromCache(d.entity) as INlpEntity; + + return d.value === "any" ? { match: "entity", - entity: d.entity.name, + entity: entity.name, } : { match: "value", - entity: d.entity.name, + entity: entity.name, value: d.value, - }, - ), + }; + }), ); }} getOptionLabel={(option) => { - return `${option.entity.name}=${option.value}`; + const entity = getNlpEntityFromCache(option.entity) as INlpEntity; + + return `${entity.name}=${option.value}`; }} groupBy={(option) => { - return option.entity.name; + const entity = getNlpEntityFromCache(option.entity) as INlpEntity; + + return entity.name; }} renderGroup={(params) => (
  • @@ -188,23 +197,25 @@ const PatternInput: FC = ({ value, onChange, idx }) => { )} preprocess={(options) => { return options.reduce((acc, curr) => { - if (curr.entity.lookups.includes("keywords")) { + const entity = getNlpEntityFromCache(curr.entity) as INlpEntity; + + if (entity.lookups.includes("keywords")) { const exists = acc.find( - ({ value, id }) => value === "any" && id === curr.entity.id, + ({ value, id }) => value === "any" && id === entity.id, ); if (!exists) { acc.push({ - entity: curr.entity, - id: curr.entity.id, + entity: entity.id, + id: entity.id, value: "any", - } as INlpValueFull); + } as INlpValue); } } acc.push(curr); return acc; - }, [] as INlpValueFull[]); + }, [] as INlpValue[]); }} /> ) : null} diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 02d62750..548e1763 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -550,7 +550,8 @@ "no_data": "No data", "code": "Code", "is_default": "Default", - "is_rtl": "RTL" + "is_rtl": "RTL", + "original_text": "Original Text" }, "placeholder": { "your_username": "Your username", diff --git a/frontend/src/i18n/fr/translation.json b/frontend/src/i18n/fr/translation.json index f9591047..1bd807dc 100644 --- a/frontend/src/i18n/fr/translation.json +++ b/frontend/src/i18n/fr/translation.json @@ -550,7 +550,8 @@ "no_data": "Pas de données", "code": "Code", "is_default": "Par Défaut", - "is_rtl": "RTL" + "is_rtl": "RTL", + "original_text": "Texte par défaut" }, "placeholder": { "your_username": "Votre nom d'utilisateur",