Merge branch 'main' into feat/annotate-sample-with-keyword-entities

This commit is contained in:
Med Marrouchi
2025-03-03 20:28:31 +01:00
committed by GitHub
135 changed files with 1238 additions and 665 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 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.
@@ -25,9 +25,11 @@ 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';
import { InvitationRepository } from '@/user/repositories/invitation.repository';
import { PermissionRepository } from '@/user/repositories/permission.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { InvitationModel } from '@/user/schemas/invitation.schema';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel } from '@/user/schemas/user.schema';
@@ -86,6 +88,7 @@ describe('BlockController', () => {
LabelModel,
CategoryModel,
ContentModel,
InvitationModel,
AttachmentModel,
UserModel,
RoleModel,
@@ -102,6 +105,7 @@ describe('BlockController', () => {
UserRepository,
RoleRepository,
PermissionRepository,
InvitationRepository,
LanguageRepository,
BlockService,
LabelService,
@@ -251,6 +255,7 @@ describe('BlockController', () => {
name: 'block with nextBlocks',
nextBlocks: [hasNextBlocks.id],
patterns: ['Hi'],
outcomes: [],
trigger_labels: [],
assign_labels: [],
trigger_channels: [],

View File

@@ -6,7 +6,7 @@
* 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).
*/
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test } from '@nestjs/testing';
@@ -15,13 +15,16 @@ import { AttachmentRepository } from '@/attachment/repositories/attachment.repos
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { InvitationRepository } from '@/user/repositories/invitation.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { InvitationModel } from '@/user/schemas/invitation.schema';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { UserModel } from '@/user/schemas/user.schema';
import { RoleService } from '@/user/services/role.service';
import { UserService } from '@/user/services/user.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import { getUpdateOneError } from '@/utils/test/errors/messages';
import { labelFixtures } from '@/utils/test/fixtures/label';
@@ -48,6 +51,7 @@ describe('LabelController', () => {
let labelService: LabelService;
let label: Label;
let labelToDelete: Label;
let secondLabelToDelete: Label;
let subscriberService: SubscriberService;
beforeAll(async () => {
@@ -61,6 +65,7 @@ describe('LabelController', () => {
RoleModel,
PermissionModel,
SubscriberModel,
InvitationModel,
AttachmentModel,
]),
],
@@ -73,6 +78,7 @@ describe('LabelController', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
SubscriberService,
SubscriberRepository,
EventEmitter2,
@@ -87,6 +93,9 @@ describe('LabelController', () => {
labelToDelete = (await labelService.findOne({
name: 'TEST_TITLE_2',
})) as Label;
secondLabelToDelete = (await labelService.findOne({
name: 'TEST_TITLE_3',
})) as Label;
});
afterEach(jest.clearAllMocks);
@@ -230,4 +239,32 @@ describe('LabelController', () => {
).rejects.toThrow(getUpdateOneError(Label.name, labelToDelete.id));
});
});
describe('deleteMany', () => {
it('should delete multiple labels', async () => {
const valuesToDelete = [label.id, secondLabelToDelete.id];
const result = await labelController.deleteMany(valuesToDelete);
expect(result.deletedCount).toEqual(valuesToDelete.length);
const remainingValues = await labelService.find({
_id: { $in: valuesToDelete },
});
expect(remainingValues.length).toBe(0);
});
it('should throw BadRequestException when no IDs are provided', async () => {
await expect(labelController.deleteMany([])).rejects.toThrow(
BadRequestException,
);
});
it('should throw NotFoundException when provided IDs do not exist', async () => {
const nonExistentIds = [NOT_FOUND_ID, NOT_FOUND_ID.replace(/9/g, '8')];
await expect(labelController.deleteMany(nonExistentIds)).rejects.toThrow(
NotFoundException,
);
});
});
});

View File

@@ -7,6 +7,7 @@
*/
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -24,6 +25,7 @@ import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
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 { PopulatePipe } from '@/utils/pipes/populate.pipe';
@@ -125,4 +127,29 @@ export class LabelController extends BaseController<
}
return result;
}
/**
* Deletes multiple Labels by their IDs.
* @param ids - IDs of Labels to be deleted.
* @returns A Promise that resolves to the deletion result.
*/
@CsrfCheck(true)
@Delete('')
@HttpCode(204)
async deleteMany(@Body('ids') ids: string[]): Promise<DeleteResult> {
if (!ids || ids.length === 0) {
throw new BadRequestException('No IDs provided for deletion.');
}
const deleteResult = await this.labelService.deleteMany({
_id: { $in: ids },
});
if (deleteResult.deletedCount === 0) {
this.logger.warn(`Unable to delete Labels with provided IDs: ${ids}`);
throw new NotFoundException('Labels with provided IDs not found');
}
this.logger.log(`Successfully deleted Labels with IDs: ${ids}`);
return deleteResult;
}
}

View File

@@ -22,8 +22,10 @@ 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';
import { InvitationRepository } from '@/user/repositories/invitation.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { InvitationModel } from '@/user/schemas/invitation.schema';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
@@ -71,6 +73,7 @@ describe('MessageController', () => {
MessageModel,
UserModel,
RoleModel,
InvitationModel,
PermissionModel,
AttachmentModel,
MenuModel,
@@ -85,6 +88,7 @@ describe('MessageController', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
SubscriberRepository,
ChannelService,
AttachmentService,

View File

@@ -14,8 +14,10 @@ import { AttachmentRepository } from '@/attachment/repositories/attachment.repos
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { InvitationRepository } from '@/user/repositories/invitation.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { InvitationModel } from '@/user/schemas/invitation.schema';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
@@ -63,6 +65,7 @@ describe('SubscriberController', () => {
LabelModel,
UserModel,
RoleModel,
InvitationModel,
PermissionModel,
AttachmentModel,
]),
@@ -79,6 +82,7 @@ describe('SubscriberController', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
EventEmitter2,
AttachmentService,
AttachmentRepository,

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 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.
@@ -45,6 +45,14 @@ export class BlockCreateDto {
@IsPatternList({ message: 'Patterns list is invalid' })
patterns?: Pattern[] = [];
@ApiPropertyOptional({
description: "Block's outcomes",
type: Array,
})
@IsOptional()
@IsArray({ message: 'Outcomes are invalid' })
outcomes?: string[] = [];
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
@IsOptional()
@IsArray()
@@ -120,6 +128,7 @@ export class BlockCreateDto {
export class BlockUpdateDto extends PartialType(
OmitType(BlockCreateDto, [
'patterns',
'outcomes',
'trigger_labels',
'assign_labels',
'trigger_channels',
@@ -130,6 +139,14 @@ export class BlockUpdateDto extends PartialType(
@IsPatternList({ message: 'Patterns list is invalid' })
patterns?: Pattern[];
@ApiPropertyOptional({
description: "Block's outcomes",
type: Array,
})
@IsOptional()
@IsArray({ message: 'Outcomes are invalid' })
outcomes?: string[];
@ApiPropertyOptional({ description: 'Block trigger labels', type: Array })
@IsOptional()
@IsArray()

View File

@@ -54,15 +54,20 @@ export class CategoryRepository extends BaseRepository<
criteria: TFilterQuery<Category>,
) {
criteria = query.getQuery();
const ids = Array.isArray(criteria._id) ? criteria._id : [criteria._id];
const ids = Array.isArray(criteria._id?.$in)
? criteria._id.$in
: Array.isArray(criteria._id)
? criteria._id
: [criteria._id];
for (const id of ids) {
const associatedBlocks = await this.blockService.findOne({
category: id,
});
if (associatedBlocks) {
const category = await this.findOne({ _id: id });
throw new ForbiddenException(
`Category ${id} has blocks associated with it`,
`Category ${category?.label || id} has blocks associated with it`,
);
}
}

View File

@@ -82,9 +82,13 @@ export class LabelRepository extends BaseRepository<
>,
_criteria: TFilterQuery<Label>,
): Promise<void> {
const labels = await this.find(
typeof _criteria === 'string' ? { _id: _criteria } : _criteria,
);
const ids = Array.isArray(_criteria._id?.$in)
? _criteria._id.$in
: Array.isArray(_criteria._id)
? _criteria._id
: [_criteria._id];
const labels = await this.find({ _id: { $in: ids } });
this.eventEmitter.emit('hook:label:delete', labels);
}
}

View File

@@ -42,6 +42,12 @@ export class BlockStub extends BaseSchema {
})
patterns: Pattern[];
@Prop({
type: Object,
default: [],
})
outcomes: string[];
@Prop([
{
type: MongooseSchema.Types.ObjectId,

View File

@@ -34,3 +34,11 @@ export type PostBackButton = z.infer<typeof postBackButtonSchema>;
export type WebUrlButton = z.infer<typeof webUrlButtonSchema>;
export type Button = z.infer<typeof buttonSchema>;
export enum PayloadType {
location = 'location',
attachments = 'attachments',
quick_reply = 'quick_reply',
button = 'button',
outcome = 'outcome',
}

View File

@@ -6,14 +6,6 @@
* 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).
*/
/*
* Copyright © 2025 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).
*/
import { z } from 'zod';
import { PluginName } from '@/plugins/types';
@@ -21,7 +13,7 @@ import { PluginName } from '@/plugins/types';
import { Message } from '../message.schema';
import { attachmentPayloadSchema } from './attachment';
import { buttonSchema } from './button';
import { buttonSchema, PayloadType } from './button';
import { contentOptionsSchema } from './options';
import { QuickReplyType, stdQuickReplySchema } from './quick-reply';
@@ -62,6 +54,7 @@ export enum OutgoingMessageFormat {
attachment = 'attachment',
list = 'list',
carousel = 'carousel',
system = 'system',
}
export const outgoingMessageFormatSchema = z.nativeEnum(OutgoingMessageFormat);
@@ -85,13 +78,6 @@ export const fileTypeSchema = z.nativeEnum(FileType);
export type FileTypeLiteral = z.infer<typeof fileTypeSchema>;
export enum PayloadType {
location = 'location',
attachments = 'attachments',
quick_reply = 'quick_reply',
button = 'button',
}
export const payloadTypeSchema = z.nativeEnum(PayloadType);
export type PayloadTypeLiteral = z.infer<typeof payloadTypeSchema>;
@@ -154,6 +140,15 @@ export type StdOutgoingAttachmentMessage = z.infer<
typeof stdOutgoingAttachmentMessageSchema
>;
export const stdOutgoingSystemMessageSchema = z.object({
outcome: z.string().optional(), // "any" or any other string (in snake case)
data: z.any().optional(),
});
export type StdOutgoingSystemMessage = z.infer<
typeof stdOutgoingSystemMessageSchema
>;
export const pluginNameSchema = z
.string()
.regex(/-plugin$/) as z.ZodType<PluginName>;
@@ -297,7 +292,16 @@ export type StdOutgoingAttachmentEnvelope = z.infer<
typeof stdOutgoingAttachmentEnvelopeSchema
>;
export const stdOutgoingEnvelopeSchema = z.union([
export const stdOutgoingSystemEnvelopeSchema = z.object({
format: z.literal(OutgoingMessageFormat.system),
message: stdOutgoingSystemMessageSchema,
});
export type StdOutgoingSystemEnvelope = z.infer<
typeof stdOutgoingSystemEnvelopeSchema
>;
export const stdOutgoingMessageEnvelopeSchema = z.union([
stdOutgoingTextEnvelopeSchema,
stdOutgoingQuickRepliesEnvelopeSchema,
stdOutgoingButtonsEnvelopeSchema,
@@ -305,6 +309,15 @@ export const stdOutgoingEnvelopeSchema = z.union([
stdOutgoingAttachmentEnvelopeSchema,
]);
export type StdOutgoingMessageEnvelope = z.infer<
typeof stdOutgoingMessageEnvelopeSchema
>;
export const stdOutgoingEnvelopeSchema = z.union([
stdOutgoingMessageEnvelopeSchema,
stdOutgoingSystemEnvelopeSchema,
]);
export type StdOutgoingEnvelope = z.infer<typeof stdOutgoingEnvelopeSchema>;
// is-valid-message-text validation

View File

@@ -8,7 +8,7 @@
import { z } from 'zod';
import { PayloadType } from './message';
import { PayloadType } from './button';
export const payloadPatternSchema = z.object({
label: z.string(),

View File

@@ -9,6 +9,7 @@
import { z } from 'zod';
import { attachmentPayloadSchema } from './attachment';
import { PayloadType } from './button';
export enum QuickReplyType {
text = 'text',
@@ -24,11 +25,11 @@ export const cordinatesSchema = z.object({
export const payloadSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('location'),
type: z.literal(PayloadType.location),
coordinates: cordinatesSchema,
}),
z.object({
type: z.literal('attachments'),
type: z.literal(PayloadType.attachments),
attachment: attachmentPayloadSchema,
}),
]);

View File

@@ -18,6 +18,7 @@ import {
subscriberWithLabels,
subscriberWithoutLabels,
} from '@/channel/lib/__test__/subscriber.mock';
import { PayloadType } from '@/chat/schemas/types/button';
import { ContentTypeRepository } from '@/cms/repositories/content-type.repository';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
@@ -64,7 +65,7 @@ import { Category, CategoryModel } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
import { FileType } from '../schemas/types/attachment';
import { Context } from '../schemas/types/context';
import { PayloadType, StdOutgoingListMessage } from '../schemas/types/message';
import { StdOutgoingListMessage } from '../schemas/types/message';
import { SubscriberContext } from '../schemas/types/subscriberContext';
import { CategoryRepository } from './../repositories/category.repository';

View File

@@ -7,9 +7,11 @@
*/
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AttachmentService } from '@/attachment/services/attachment.service';
import EventWrapper from '@/channel/lib/EventWrapper';
import { ChannelName } from '@/channel/types';
import { ContentService } from '@/cms/services/content.service';
import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings';
import { NLU } from '@/helper/types';
@@ -25,11 +27,14 @@ import { getRandom } from '@/utils/helpers/safeRandom';
import { BlockDto } from '../dto/block.dto';
import { BlockRepository } from '../repositories/block.repository';
import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema';
import { Label } from '../schemas/label.schema';
import { Subscriber } from '../schemas/subscriber.schema';
import { Context } from '../schemas/types/context';
import {
BlockMessage,
OutgoingMessageFormat,
StdOutgoingEnvelope,
StdOutgoingSystemEnvelope,
} from '../schemas/types/message';
import { NlpPattern, PayloadPattern } from '../schemas/types/pattern';
import { Payload, StdQuickReply } from '../schemas/types/quick-reply';
@@ -55,10 +60,75 @@ export class BlockService extends BaseService<
super(repository);
}
/**
* Filters an array of blocks based on the specified channel.
*
* This function ensures that only blocks that are either:
* - Not restricted to specific trigger channels (`trigger_channels` is undefined or empty), or
* - Explicitly allow the given channel (or the console channel)
*
* are included in the returned array.
*
* @param blocks - The list of blocks to be filtered.
* @param channel - The name of the channel to filter blocks by.
*
* @returns The filtered array of blocks that are allowed for the given channel.
*/
filterBlocksByChannel<B extends Block | BlockFull>(
blocks: B[],
channel: ChannelName,
) {
return blocks.filter((b) => {
return (
!b.trigger_channels ||
b.trigger_channels.length === 0 ||
[...b.trigger_channels, CONSOLE_CHANNEL_NAME].includes(channel)
);
});
}
/**
* Filters an array of blocks based on subscriber labels.
*
* This function selects blocks that either:
* - Have no trigger labels (making them applicable to all subscribers), or
* - Contain at least one trigger label that matches a label from the provided list.
*
* The filtered blocks are then **sorted** in descending order by the number of trigger labels,
* ensuring that blocks with more specific targeting (more trigger labels) are prioritized.
*
* @param blocks - The list of blocks to be filtered.
* @param labels - The list of subscriber labels to match against.
* @returns The filtered and sorted list of blocks.
*/
filterBlocksBySubscriberLabels<B extends Block | BlockFull>(
blocks: B[],
profile?: Subscriber,
) {
if (!profile) {
return blocks;
}
return (
blocks
.filter((b) => {
const triggerLabels = b.trigger_labels.map((l) =>
typeof l === 'string' ? l : l.id,
);
return (
triggerLabels.length === 0 ||
triggerLabels.some((l) => profile.labels.includes(l))
);
})
// Priority goes to block who target users with labels
.sort((a, b) => b.trigger_labels.length - a.trigger_labels.length)
);
}
/**
* Find a block whose patterns matches the received event
*
* @param blocks blocks Starting/Next blocks in the conversation flow
* @param filteredBlocks blocks Starting/Next blocks in the conversation flow
* @param event Received channel's message
*
* @returns The block that matches
@@ -75,37 +145,15 @@ export class BlockService extends BaseService<
let block: BlockFull | undefined = undefined;
const payload = event.getPayload();
// Perform a filter on the specific channels
const channel = event.getHandler().getName();
blocks = blocks.filter((b) => {
return (
!b.trigger_channels ||
b.trigger_channels.length === 0 ||
[...b.trigger_channels, CONSOLE_CHANNEL_NAME].includes(channel)
);
});
// Perform a filter on trigger labels
let userLabels: string[] = [];
const profile = event.getSender();
if (profile && Array.isArray(profile.labels)) {
userLabels = profile.labels.map((l) => l);
}
blocks = blocks
.filter((b) => {
const trigger_labels = b.trigger_labels.map(({ id }) => id);
return (
trigger_labels.length === 0 ||
trigger_labels.some((l) => userLabels.includes(l))
);
})
// Priority goes to block who target users with labels
.sort((a, b) => b.trigger_labels.length - a.trigger_labels.length);
// Perform a filter to get the candidates blocks
const filteredBlocks = this.filterBlocksBySubscriberLabels(
this.filterBlocksByChannel(blocks, event.getHandler().getName()),
event.getSender(),
);
// Perform a payload match & pick last createdAt
if (payload) {
block = blocks
block = filteredBlocks
.filter((b) => {
return this.matchPayload(payload, b);
})
@@ -129,7 +177,7 @@ export class BlockService extends BaseService<
}
// Perform a text pattern match
block = blocks
block = filteredBlocks
.filter((b) => {
return this.matchText(text, b);
})
@@ -139,7 +187,7 @@ export class BlockService extends BaseService<
if (!block && nlp) {
// Find block pattern having the best match of nlp entities
let nlpBest = 0;
blocks.forEach((b, index, self) => {
filteredBlocks.forEach((b, index, self) => {
const nlpPattern = this.matchNLP(nlp, b);
if (nlpPattern && nlpPattern.length > nlpBest) {
nlpBest = nlpPattern.length;
@@ -293,6 +341,36 @@ export class BlockService extends BaseService<
});
}
/**
* Matches an outcome-based block from a list of available blocks
* based on the outcome of a system message.
*
* @param blocks - An array of blocks to search for a matching outcome.
* @param envelope - The system message envelope containing the outcome to match.
*
* @returns - Returns the first matching block if found, otherwise returns `undefined`.
*/
matchOutcome(
blocks: Block[],
event: EventWrapper<any, any>,
envelope: StdOutgoingSystemEnvelope,
) {
// Perform a filter to get the candidates blocks
const filteredBlocks = this.filterBlocksBySubscriberLabels(
this.filterBlocksByChannel(blocks, event.getHandler().getName()),
event.getSender(),
);
return filteredBlocks.find((b) => {
return b.patterns
.filter(
(p) => typeof p === 'object' && 'type' in p && p.type === 'outcome',
)
.some((p: PayloadPattern) =>
['any', envelope.message.outcome].includes(p.value),
);
});
}
/**
* Replaces tokens with their context variables values in the provided text message
*
@@ -604,4 +682,32 @@ export class BlockService extends BaseService<
}
throw new Error('Invalid message format.');
}
/**
* Updates the `trigger_labels` and `assign_labels` fields of a block when a label is deleted.
*
*
* This method removes the deleted label from the `trigger_labels` and `assign_labels` fields of all blocks that have the label.
*
* @param label The label that is being deleted.
*/
@OnEvent('hook:label:delete')
async handleLabelDelete(labels: Label[]) {
const blocks = await this.find({
$or: [
{ trigger_labels: { $in: labels.map((l) => l.id) } },
{ assign_labels: { $in: labels.map((l) => l.id) } },
],
});
for (const block of blocks) {
const trigger_labels = block.trigger_labels.filter(
(labelId) => !labels.find((l) => l.id === labelId),
);
const assign_labels = block.assign_labels.filter(
(labelId) => !labels.find((l) => l.id === labelId),
);
await this.updateOne(block.id, { trigger_labels, assign_labels });
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 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.
@@ -201,7 +201,7 @@ describe('BlockService', () => {
let hasBotSpoken = false;
const clearMock = jest
.spyOn(botService, 'findBlockAndSendReply')
.spyOn(botService, 'triggerBlock')
.mockImplementation(
(
actualEvent: WebEventWrapper<typeof WEB_CHANNEL_NAME>,

View File

@@ -21,8 +21,11 @@ import {
getDefaultConversationContext,
} from '../schemas/conversation.schema';
import { Context } from '../schemas/types/context';
import { IncomingMessageType } from '../schemas/types/message';
import { SubscriberContext } from '../schemas/types/subscriberContext';
import {
IncomingMessageType,
OutgoingMessageFormat,
StdOutgoingMessageEnvelope,
} from '../schemas/types/message';
import { BlockService } from './block.service';
import { ConversationService } from './conversation.service';
@@ -40,40 +43,24 @@ export class BotService {
) {}
/**
* Sends a processed message to the user based on a specified content block.
* Replaces tokens within the block with context data, handles fallback scenarios,
* and assigns relevant labels to the user.
* Sends a message to the subscriber via the appropriate messaging channel and handles related events.
*
* @param event - The incoming message or action that triggered the bot's response.
* @param envelope - The outgoing message envelope containing the bot's response.
* @param block - The content block containing the message and options to be sent.
* @param context - Optional. The conversation context object, containing relevant data for personalization.
* @param fallback - Optional. Boolean flag indicating if this is a fallback message when no appropriate response was found.
* @param conversationId - Optional. The conversation ID to link the message to a specific conversation thread.
*
* @returns A promise that resolves with the message response, including the message ID.
*/
async sendMessageToSubscriber(
envelope: StdOutgoingMessageEnvelope,
event: EventWrapper<any, any>,
block: BlockFull,
context?: Context,
fallback?: boolean,
conservationId?: string,
) {
context = context || getDefaultConversationContext();
fallback = typeof fallback !== 'undefined' ? fallback : false;
const options = block.options;
this.logger.debug('Sending message ... ', event.getSenderForeignId());
// Process message : Replace tokens with context data and then send the message
const recipient = event.getSender();
const envelope = await this.blockService.processMessage(
block,
context,
recipient?.context as SubscriberContext,
fallback,
conservationId,
);
// Send message through the right channel
this.logger.debug('Sending message ... ', event.getSenderForeignId());
const response = await event
.getHandler()
.sendMessage(event, envelope, options, context);
@@ -114,35 +101,56 @@ export class BotService {
);
this.logger.debug('Assigned labels ', blockLabels);
return response;
}
/**
* Finds an appropriate reply block and sends it to the user.
* If there are additional blocks or attached blocks, it continues the conversation flow.
* Ends the conversation if no further blocks are available.
* Processes and executes a block, handling its associated messages and flow logic.
*
* The function performs the following steps:
* 1. Retrieves the conversation context and recipient information.
* 2. Generates an outgoing message envelope from the block.
* 3. Sends the message to the subscriber unless it's a system message.
* 4. Handles block chaining:
* - If the block has an attached block, it recursively triggers the attached block.
* - If the block has multiple possible next blocks, it determines the next block based on the outcome of the system message.
* - If there are next blocks but no outcome-based matching, it updates the conversation state for the next steps.
* 5. If no further blocks exist, it ends the flow execution.
*
* @param event - The incoming message or action that initiated this response.
* @param convo - The current conversation context and flow.
* @param block - The content block to be processed and sent.
* @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found.
*
* @returns A promise that continues or ends the conversation based on available blocks.
* @returns A promise that either continues or ends the flow execution based on the available blocks.
*/
async findBlockAndSendReply(
async triggerBlock(
event: EventWrapper<any, any>,
convo: Conversation,
block: BlockFull,
fallback: boolean,
fallback: boolean = false,
) {
try {
await this.sendMessageToSubscriber(
event,
const context = convo.context || getDefaultConversationContext();
const recipient = event.getSender();
const envelope = await this.blockService.processMessage(
block,
convo.context,
context,
recipient?.context,
fallback,
convo.id,
);
if (envelope.format !== OutgoingMessageFormat.system) {
await this.sendMessageToSubscriber(
envelope,
event,
block,
context,
fallback,
);
}
if (block.attachedBlock) {
// Sequential messaging ?
try {
@@ -154,12 +162,7 @@ export class BotService {
'No attached block to be found with id ' + block.attachedBlock,
);
}
return await this.findBlockAndSendReply(
event,
convo,
attachedBlock,
fallback,
);
return await this.triggerBlock(event, convo, attachedBlock, fallback);
} catch (err) {
this.logger.error('Unable to retrieve attached block', err);
this.eventEmitter.emit('hook:conversation:end', convo, true);
@@ -168,20 +171,47 @@ export class BotService {
Array.isArray(block.nextBlocks) &&
block.nextBlocks.length > 0
) {
// Conversation continues : Go forward to next blocks
this.logger.debug('Conversation continues ...', convo.id);
const nextIds = block.nextBlocks.map(({ id }) => id);
try {
await this.conversationService.updateOne(convo.id, {
current: block.id,
next: nextIds,
});
if (envelope.format === OutgoingMessageFormat.system) {
// System message: Trigger the next block based on the outcome
this.logger.debug(
'Matching the outcome against the next blocks ...',
convo.id,
);
const match = this.blockService.matchOutcome(
block.nextBlocks,
event,
envelope,
);
if (match) {
const nextBlock = await this.blockService.findOneAndPopulate(
match.id,
);
if (!nextBlock) {
throw new Error(
'No attached block to be found with id ' +
block.attachedBlock,
);
}
return await this.triggerBlock(event, convo, nextBlock, fallback);
} else {
this.logger.warn(
'Block outcome did not match any of the next blocks',
convo,
);
}
} else {
// Conversation continues : Go forward to next blocks
this.logger.debug('Conversation continues ...', convo.id);
const nextIds = block.nextBlocks.map(({ id }) => id);
await this.conversationService.updateOne(convo.id, {
current: block.id,
next: nextIds,
});
}
} catch (err) {
this.logger.error(
'Unable to update conversation when going next',
convo,
err,
);
this.logger.error('Unable to continue the flow', convo, err);
return;
}
} else {
@@ -275,12 +305,7 @@ export class BotService {
// Otherwise, old captured const value may be replaced by another const value
!fallback,
);
await this.findBlockAndSendReply(
event,
updatedConversation,
next,
fallback,
);
await this.triggerBlock(event, updatedConversation, next, fallback);
} catch (err) {
this.logger.error('Unable to store context data!', err);
return this.eventEmitter.emit('hook:conversation:end', convo, true);
@@ -376,12 +401,7 @@ export class BotService {
subscriber.id,
block.name,
);
return this.findBlockAndSendReply(
event,
updatedConversation,
block,
false,
);
return this.triggerBlock(event, updatedConversation, block, false);
} catch (err) {
this.logger.error('Unable to store context data!', err);
this.eventEmitter.emit('hook:conversation:end', convo, true);
@@ -459,7 +479,7 @@ export class BotService {
'No global fallback block defined, sending a message ...',
err,
);
this.sendMessageToSubscriber(event, {
const globalFallbackBlock = {
id: 'global-fallback',
name: 'Global Fallback',
message: settings.chatbot_settings.fallback_message,
@@ -473,7 +493,19 @@ export class BotService {
createdAt: new Date(),
updatedAt: new Date(),
attachedBlock: null,
} as any as BlockFull);
} as any as BlockFull;
const envelope = await this.blockService.processMessage(
globalFallbackBlock,
getDefaultConversationContext(),
{ vars: {} }, // @TODO: use subscriber ctx
);
await this.sendMessageToSubscriber(
envelope as StdOutgoingMessageEnvelope,
event,
globalFallbackBlock,
);
}
}
// Do nothing ...

View File

@@ -14,8 +14,10 @@ import { AttachmentRepository } from '@/attachment/repositories/attachment.repos
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { InvitationRepository } from '@/user/repositories/invitation.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { InvitationModel } from '@/user/schemas/invitation.schema';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
@@ -62,6 +64,7 @@ describe('MessageService', () => {
UserModel,
RoleModel,
PermissionModel,
InvitationModel,
SubscriberModel,
MessageModel,
AttachmentModel,
@@ -75,6 +78,7 @@ describe('MessageService', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
SubscriberService,
SubscriberRepository,
MessageService,

View File

@@ -14,8 +14,10 @@ import { AttachmentRepository } from '@/attachment/repositories/attachment.repos
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { InvitationRepository } from '@/user/repositories/invitation.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
import { InvitationModel } from '@/user/schemas/invitation.schema';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { RoleModel } from '@/user/schemas/role.schema';
import { User, UserModel } from '@/user/schemas/user.schema';
@@ -55,6 +57,7 @@ describe('SubscriberService', () => {
LabelModel,
UserModel,
RoleModel,
InvitationModel,
PermissionModel,
AttachmentModel,
]),
@@ -68,6 +71,7 @@ describe('SubscriberService', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
LoggerService,
EventEmitter2,
AttachmentService,

View File

@@ -30,6 +30,7 @@ import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { SubscriberDto, SubscriberUpdateDto } from '../dto/subscriber.dto';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Label } from '../schemas/label.schema';
import {
Subscriber,
SubscriberFull,
@@ -208,4 +209,24 @@ export class SubscriberService extends BaseService<
}
}
}
/**
* Updates the `labels` field of a subscriber when a label is deleted.
*
* This method removes the deleted label from the `labels` field of all subscribers that have the label.
*
* @param label The label that is being deleted.
*/
@OnEvent('hook:label:delete')
async handleLabelDelete(labels: Label[]) {
const subscribers = await this.find({
labels: { $in: labels.map((l) => l.id) },
});
for (const subscriber of subscribers) {
const updatedLabels = subscriber.labels.filter(
(label) => !labels.find((l) => l.id === label),
);
await this.updateOne(subscriber.id, { labels: updatedLabels });
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 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.
@@ -9,11 +9,8 @@
import { forwardRef, Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentModule } from '@/attachment/attachment.module';
import { ChatModule } from '@/chat/chat.module';
import { AttachmentModel } from '../attachment/schemas/attachment.schema';
import { ContentTypeController } from './controllers/content-type.controller';
import { ContentController } from './controllers/content.controller';
import { MenuController } from './controllers/menu.controller';
@@ -29,13 +26,7 @@ import { MenuService } from './services/menu.service';
@Module({
imports: [
MongooseModule.forFeature([
ContentModel,
ContentTypeModel,
AttachmentModel,
MenuModel,
]),
AttachmentModule,
MongooseModule.forFeature([ContentModel, ContentTypeModel, MenuModel]),
forwardRef(() => ChatModule),
],
controllers: [ContentController, ContentTypeController, MenuController],

View File

@@ -1,24 +1,16 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 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).
*/
import fs from 'fs';
import { NotFoundException } from '@nestjs/common/exceptions';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import {
Attachment,
AttachmentModel,
} from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { LoggerService } from '@/logger/logger.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
@@ -48,10 +40,8 @@ describe('ContentController', () => {
let contentController: ContentController;
let contentService: ContentService;
let contentTypeService: ContentTypeService;
let attachmentService: AttachmentService;
let contentType: ContentType | null;
let content: Content | null;
let attachment: Attachment | null;
let updatedContent;
let pageQuery: PageQueryDto<Content>;
@@ -60,34 +50,24 @@ describe('ContentController', () => {
controllers: [ContentController],
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
MongooseModule.forFeature([ContentTypeModel, ContentModel]),
],
providers: [
LoggerService,
ContentTypeService,
ContentService,
ContentRepository,
AttachmentService,
ContentTypeRepository,
AttachmentRepository,
EventEmitter2,
],
}).compile();
contentController = module.get<ContentController>(ContentController);
contentService = module.get<ContentService>(ContentService);
attachmentService = module.get<AttachmentService>(AttachmentService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
contentType = await contentTypeService.findOne({ name: 'Product' });
content = await contentService.findOne({
title: 'Jean',
});
attachment = await attachmentService.findOne({
name: 'store1.jpg',
});
pageQuery = getPageQuery<Content>({
limit: 1,
@@ -243,91 +223,74 @@ describe('ContentController', () => {
});
describe('import', () => {
const mockCsvData: string = `other,title,status,image
should not appear,store 3,true,image.jpg`;
const file: Express.Multer.File = {
buffer: Buffer.from(mockCsvData, 'utf-8'),
originalname: 'test.csv',
mimetype: 'text/csv',
size: mockCsvData.length,
fieldname: 'file',
encoding: '7bit',
stream: null,
destination: '',
filename: '',
path: '',
} as unknown as Express.Multer.File;
it('should import content from a CSV file', async () => {
const mockCsvData: string = `other,title,status,image
should not appear,store 3,true,image.jpg`;
const mockCsvContentDto: ContentCreateDto = {
entity: '0',
title: 'store 3',
status: true,
dynamicFields: {
image: 'image.jpg',
},
};
jest.spyOn(contentService, 'createMany');
jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true);
jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockCsvData);
const contentType = await contentTypeService.findOne({
const mockContentType = {
id: '0',
name: 'Store',
});
const result = await contentController.import({
idFileToImport: attachment!.id,
idTargetContentType: contentType!.id,
});
expect(contentService.createMany).toHaveBeenCalledWith([
{ ...mockCsvContentDto, entity: contentType!.id },
} as unknown as ContentType;
jest
.spyOn(contentTypeService, 'findOne')
.mockResolvedValueOnce(mockContentType);
jest.spyOn(contentService, 'parseAndSaveDataset').mockResolvedValueOnce([
{
entity: mockContentType.id,
title: 'store 3',
status: true,
dynamicFields: {
image: 'image.jpg',
},
id: '',
createdAt: new Date(),
updatedAt: new Date(),
},
]);
expect(result).toEqualPayload(
[
{
...mockCsvContentDto,
entity: contentType!.id,
},
],
[...IGNORED_TEST_FIELDS, 'rag'],
const result = await contentController.import(file, mockContentType.id);
expect(contentService.parseAndSaveDataset).toHaveBeenCalledWith(
mockCsvData,
mockContentType.id,
mockContentType,
);
expect(result).toEqualPayload([
{
entity: mockContentType.id,
title: 'store 3',
status: true,
dynamicFields: {
image: 'image.jpg',
},
id: '',
},
]);
});
it('should throw NotFoundException if content type is not found', async () => {
jest.spyOn(contentTypeService, 'findOne').mockResolvedValueOnce(null);
await expect(
contentController.import({
idFileToImport: attachment!.id,
idTargetContentType: NOT_FOUND_ID,
}),
contentController.import(file, 'INVALID_ID'),
).rejects.toThrow(new NotFoundException('Content type is not found'));
});
it('should throw NotFoundException if file is not found in attachment database', async () => {
const contentType = await contentTypeService.findOne({
name: 'Product',
});
jest.spyOn(contentTypeService, 'findOne');
await expect(
contentController.import({
idFileToImport: NOT_FOUND_ID,
idTargetContentType: contentType!.id.toString(),
}),
).rejects.toThrow(new NotFoundException('File does not exist'));
it('should throw NotFoundException if idTargetContentType is missing', async () => {
await expect(contentController.import(file, '')).rejects.toThrow(
new NotFoundException('Missing parameter'),
);
});
it('should throw NotFoundException if file does not exist in the given path ', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
await expect(
contentController.import({
idFileToImport: attachment!.id,
idTargetContentType: contentType!.id,
}),
).rejects.toThrow(new NotFoundException('File does not exist'));
});
it.each([
['file param and content type params are missing', '', ''],
['content type param is missing', '', NOT_FOUND_ID],
['file param is missing', NOT_FOUND_ID, ''],
])(
'should throw NotFoundException if %s',
async (_message, fileToImport, targetContentType) => {
await expect(
contentController.import({
idFileToImport: fileToImport,
idTargetContentType: targetContentType,
}),
).rejects.toThrow(new NotFoundException('Missing params'));
},
);
});
});

View File

@@ -1,14 +1,11 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 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).
*/
import fs from 'fs';
import path from 'path';
import {
Body,
Controller,
@@ -20,14 +17,12 @@ import {
Patch,
Post,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { BadRequestException } from '@nestjs/common/exceptions';
import { FileInterceptor } from '@nestjs/platform-express';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import Papa from 'papaparse';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { config } from '@/config';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { BaseController } from '@/utils/generics/base-controller';
@@ -59,7 +54,6 @@ export class ContentController extends BaseController<
constructor(
private readonly contentService: ContentService,
private readonly contentTypeService: ContentTypeService,
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
) {
super(contentService);
@@ -92,29 +86,22 @@ export class ContentController extends BaseController<
/**
* Imports content from a CSV file based on the provided content type and file ID.
*
* @param idTargetContentType - The content type to match the CSV data against.
* @param idFileToImport - The ID of the file to be imported.
*
* @param idTargetContentType - The content type to match the CSV data against. *
* @returns A promise that resolves to the newly created content documents.
*/
@Get('import/:idTargetContentType/:idFileToImport')
@CsrfCheck(true)
@Post('import')
@UseInterceptors(FileInterceptor('file'))
async import(
@Param()
{
idTargetContentType: targetContentType,
idFileToImport: fileToImport,
}: {
idTargetContentType: string;
idFileToImport: string;
},
@UploadedFile() file: Express.Multer.File,
@Query('idTargetContentType')
targetContentType: string,
) {
// Check params
if (!fileToImport || !targetContentType) {
this.logger.warn(`Parameters are missing`);
throw new NotFoundException(`Missing params`);
const datasetContent = file.buffer.toString('utf-8');
if (!targetContentType) {
this.logger.warn(`Parameter is missing`);
throw new NotFoundException(`Missing parameter`);
}
// Find the content type that corresponds to the given content
const contentType =
await this.contentTypeService.findOne(targetContentType);
if (!contentType) {
@@ -124,56 +111,11 @@ export class ContentController extends BaseController<
throw new NotFoundException(`Content type is not found`);
}
// Get file location
const file = await this.attachmentService.findOne(fileToImport);
// Check if file is present
const filePath = file
? path.join(config.parameters.uploadDir, file.location)
: undefined;
if (!file || !filePath || !fs.existsSync(filePath)) {
this.logger.warn(`Failed to find file type with id ${fileToImport}.`);
throw new NotFoundException(`File does not exist`);
}
//read file sync
const data = fs.readFileSync(filePath, 'utf8');
const result = Papa.parse<Record<string, string | boolean | number>>(data, {
header: true,
skipEmptyLines: true,
dynamicTyping: true,
});
if (result.errors.length > 0) {
this.logger.warn(
`Errors parsing the file: ${JSON.stringify(result.errors)}`,
);
throw new BadRequestException(result.errors, {
cause: result.errors,
description: 'Error while parsing CSV',
});
}
const contentsDto = result.data.reduce(
(acc, { title, status, ...rest }) => [
...acc,
{
title: String(title),
status: Boolean(status),
entity: targetContentType,
dynamicFields: Object.keys(rest)
.filter((key) =>
contentType.fields?.map((field) => field.name).includes(key),
)
.reduce((filtered, key) => ({ ...filtered, [key]: rest[key] }), {}),
},
],
[],
return await this.contentService.parseAndSaveDataset(
datasetContent,
targetContentType,
contentType,
);
// Create content
return await this.contentService.createMany(contentsDto);
}
/**

View File

@@ -10,9 +10,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
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 { OutgoingMessageFormat } from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service';
@@ -44,19 +41,13 @@ describe('ContentService', () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installContentFixtures),
MongooseModule.forFeature([
ContentTypeModel,
ContentModel,
AttachmentModel,
]),
MongooseModule.forFeature([ContentTypeModel, ContentModel]),
],
providers: [
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
ContentTypeService,
ContentService,
AttachmentService,
LoggerService,
EventEmitter2,
],

View File

@@ -6,9 +6,9 @@
* 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).
*/
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import Papa from 'papaparse';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { StdOutgoingListMessage } from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service';
@@ -17,6 +17,7 @@ import { TFilterQuery } from '@/utils/types/filter.types';
import { ContentDto } from '../dto/content.dto';
import { ContentRepository } from '../repositories/content.repository';
import { ContentType } from '../schemas/content-type.schema';
import {
Content,
ContentFull,
@@ -32,7 +33,6 @@ export class ContentService extends BaseService<
> {
constructor(
readonly repository: ContentRepository,
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
) {
super(repository);
@@ -103,4 +103,68 @@ export class ContentService extends BaseService<
throw err;
}
}
/**
* Parses a CSV dataset and saves the content in the repository.
*
* @param data - The CSV data as a string to be parsed.
* @param targetContentType - The content type to associate with the parsed data.
* @param contentType - The content type metadata, including fields to validate the parsed data.
* @return A promise resolving to the created content objects.
*/
async parseAndSaveDataset(
data: string,
targetContentType: string,
contentType: ContentType,
) {
// Parse local CSV file
const result: {
errors: any[];
data: Array<Record<string, string>>;
} = Papa.parse(data, {
header: true,
skipEmptyLines: true,
});
if (result.errors && result.errors.length > 0) {
this.logger.warn(
`Errors parsing the file: ${JSON.stringify(result.errors)}`,
);
throw new BadRequestException(result.errors, {
cause: result.errors,
description: 'Error while parsing CSV',
});
}
if (!result.data.every((row) => row.title && row.status)) {
throw new BadRequestException(
'Missing required fields: "title" or "status"',
{
cause: 'Invalid CSV data',
description: 'CSV must include "title" and "status" columns',
},
);
}
const contentsDto = result.data.reduce(
(acc, { title, status, ...rest }) => [
...acc,
{
title: String(title),
status: Boolean(status),
entity: targetContentType,
dynamicFields: Object.keys(rest)
.filter((key) =>
contentType.fields?.map((field) => field.name).includes(key),
)
.reduce((filtered, key) => ({ ...filtered, [key]: rest[key] }), {}),
},
],
[],
);
this.logger.log(`Parsed ${result.data.length} rows from CSV.`);
try {
return await this.createMany(contentsDto);
} catch (err) {
this.logger.error('Error occurred when extracting data. ', err);
}
}
}

View File

@@ -29,7 +29,7 @@ import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto';
import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants';
import { Subscriber, SubscriberFull } from '@/chat/schemas/subscriber.schema';
import { AttachmentRef } from '@/chat/schemas/types/attachment';
import { Button, ButtonType } from '@/chat/schemas/types/button';
import { Button, ButtonType, PayloadType } from '@/chat/schemas/types/button';
import {
AnyMessage,
ContentElement,
@@ -37,7 +37,6 @@ import {
IncomingMessageType,
OutgoingMessage,
OutgoingMessageFormat,
PayloadType,
StdEventType,
StdOutgoingAttachmentMessage,
StdOutgoingButtonsMessage,

View File

@@ -9,9 +9,9 @@
import { Attachment } from '@/attachment/schemas/attachment.schema';
import EventWrapper from '@/channel/lib/EventWrapper';
import { ChannelName } from '@/channel/types';
import { PayloadType } from '@/chat/schemas/types/button';
import {
IncomingMessageType,
PayloadType,
StdEventType,
StdIncomingMessage,
} from '@/chat/schemas/types/message';

View File

@@ -136,6 +136,7 @@ describe('TranslationService', () => {
const block: Block = {
name: 'Ollama Plugin',
patterns: [],
outcomes: [],
assign_labels: [],
trigger_channels: [],
trigger_labels: [],

View File

@@ -1,11 +1,13 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 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).
*/
import { SettingType } from '@/setting/schemas/types';
import { SettingByType } from './schemas/types';
import { DEFAULT_SETTINGS } from './seeds/setting.seed-model';
@@ -22,10 +24,14 @@ declare global {
? { [K in keyof T]: TNativeType<T[K]> }
: T;
type SettingValue<K> = K['type'] extends SettingType.select
? K['options'][number]
: TNativeType<K['value']>;
type SettingObject<
T extends Omit<Setting, 'id' | 'createdAt' | 'updatedAt'>[],
> = {
[K in T[number] as K['label']]: TNativeType<K['value']>;
[K in T[number] as K['label']]: SettingValue<K>;
};
type SettingMapByType<
@@ -38,7 +44,7 @@ declare global {
T extends Omit<Setting, 'id' | 'createdAt' | 'updatedAt'>[],
> = {
[G in T[number] as G['group']]: {
[K in T[number] as K['label']]: TNativeType<K['value']>;
[K in T[number] as K['label']]: SettingValue<K>;
};
};

View File

@@ -24,10 +24,12 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { InvitationRepository } from '../repositories/invitation.repository';
import { ModelRepository } from '../repositories/model.repository';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { ModelFull, ModelModel } from '../schemas/model.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { RoleModel } from '../schemas/role.schema';
@@ -53,6 +55,7 @@ describe('ModelController', () => {
UserModel,
RoleModel,
PermissionModel,
InvitationModel,
ModelModel,
AttachmentModel,
]),
@@ -68,6 +71,7 @@ describe('ModelController', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
PermissionRepository,
EventEmitter2,
{

View File

@@ -20,9 +20,11 @@ import {
} from '@/utils/test/test';
import { PermissionCreateDto } from '../dto/permission.dto';
import { InvitationRepository } from '../repositories/invitation.repository';
import { ModelRepository } from '../repositories/model.repository';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { Model, ModelModel } from '../schemas/model.schema';
import {
Permission,
@@ -53,7 +55,12 @@ describe('PermissionController', () => {
controllers: [PermissionController],
imports: [
rootMongooseTestModule(installPermissionFixtures),
MongooseModule.forFeature([PermissionModel, ModelModel, RoleModel]),
MongooseModule.forFeature([
PermissionModel,
ModelModel,
RoleModel,
InvitationModel,
]),
],
providers: [
LoggerService,
@@ -62,6 +69,7 @@ describe('PermissionController', () => {
PermissionService,
PermissionRepository,
RoleRepository,
InvitationRepository,
ModelRepository,
EventEmitter2,
{

View File

@@ -26,9 +26,11 @@ import {
} from '@/utils/test/test';
import { RoleCreateDto, RoleUpdateDto } from '../dto/role.dto';
import { InvitationRepository } from '../repositories/invitation.repository';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { Role, RoleFull, RoleModel } from '../schemas/role.schema';
import { UserModel } from '../schemas/user.schema';
@@ -55,6 +57,7 @@ describe('RoleController', () => {
RoleModel,
PermissionModel,
UserModel,
InvitationModel,
AttachmentModel,
]),
],
@@ -65,6 +68,7 @@ describe('RoleController', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
PermissionRepository,
EventEmitter2,
AttachmentService,

View File

@@ -23,6 +23,7 @@ import {
import { ModelRepository } from '../repositories/model.repository';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { ModelModel, Model as ModelSchema } from '../schemas/model.schema';
import {
Permission,
@@ -32,6 +33,8 @@ import {
import { Role, RoleModel } from '../schemas/role.schema';
import { Action } from '../types/action.type';
import { InvitationRepository } from './invitation.repository';
describe('PermissionRepository', () => {
let modelRepository: ModelRepository;
let roleRepository: RoleRepository;
@@ -44,12 +47,18 @@ describe('PermissionRepository', () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installPermissionFixtures),
MongooseModule.forFeature([ModelModel, PermissionModel, RoleModel]),
MongooseModule.forFeature([
ModelModel,
PermissionModel,
RoleModel,
InvitationModel,
]),
],
providers: [
ModelRepository,
RoleRepository,
PermissionRepository,
InvitationRepository,
EventEmitter2,
],
}).compile();

View File

@@ -21,11 +21,13 @@ import {
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { Role, RoleFull, RoleModel } from '../schemas/role.schema';
import { User, UserModel } from '../schemas/user.schema';
import { roleFixtures } from './../../utils/test/fixtures/role';
import { InvitationRepository } from './invitation.repository';
describe('RoleRepository', () => {
let roleRepository: RoleRepository;
@@ -40,11 +42,17 @@ describe('RoleRepository', () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installPermissionFixtures),
MongooseModule.forFeature([UserModel, PermissionModel, RoleModel]),
MongooseModule.forFeature([
UserModel,
PermissionModel,
RoleModel,
InvitationModel,
]),
],
providers: [
UserRepository,
RoleRepository,
InvitationRepository,
PermissionRepository,
EventEmitter2,
],

View File

@@ -14,6 +14,7 @@ import { Model } from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { RoleDto } from '../dto/role.dto';
import { Invitation } from '../schemas/invitation.schema';
import { Permission } from '../schemas/permission.schema';
import {
Role,
@@ -34,6 +35,8 @@ export class RoleRepository extends BaseRepository<
@InjectModel(Role.name) readonly model: Model<Role>,
@InjectModel(Permission.name)
private readonly permissionModel: Model<Permission>,
@InjectModel(Invitation.name)
private readonly invitationModel: Model<Invitation>,
) {
super(eventEmitter, model, Role, ROLE_POPULATE, RoleFull);
}
@@ -49,6 +52,7 @@ export class RoleRepository extends BaseRepository<
const result = await this.model.deleteOne({ _id: id }).exec();
if (result.deletedCount > 0) {
await this.permissionModel.deleteMany({ role: id });
await this.invitationModel.deleteMany({ roles: id });
}
return result;
}

View File

@@ -26,10 +26,13 @@ import {
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { Role, RoleModel } from '../schemas/role.schema';
import { User, UserFull, UserModel } from '../schemas/user.schema';
import { InvitationRepository } from './invitation.repository';
describe('UserRepository', () => {
let roleRepository: RoleRepository;
let userRepository: UserRepository;
@@ -57,6 +60,7 @@ describe('UserRepository', () => {
UserModel,
PermissionModel,
RoleModel,
InvitationModel,
AttachmentModel,
]),
],
@@ -64,6 +68,7 @@ describe('UserRepository', () => {
LoggerService,
UserRepository,
RoleRepository,
InvitationRepository,
PermissionRepository,
EventEmitter2,
{

View File

@@ -21,8 +21,10 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { InvitationRepository } from '../repositories/invitation.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { RoleModel } from '../schemas/role.schema';
import { UserModel } from '../schemas/user.schema';
@@ -43,6 +45,7 @@ describe('AuthService', () => {
UserModel,
RoleModel,
PermissionModel,
InvitationModel,
AttachmentModel,
]),
],
@@ -53,6 +56,7 @@ describe('AuthService', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
JwtService,
EventEmitter2,
AttachmentService,

View File

@@ -32,8 +32,10 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { InvitationRepository } from '../repositories/invitation.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { RoleModel } from '../schemas/role.schema';
import { User, UserModel } from '../schemas/user.schema';
@@ -60,6 +62,7 @@ describe('PasswordResetService', () => {
PermissionModel,
AttachmentModel,
LanguageModel,
InvitationModel,
]),
JwtModule,
],
@@ -70,6 +73,7 @@ describe('PasswordResetService', () => {
AttachmentService,
AttachmentRepository,
RoleRepository,
InvitationRepository,
LanguageService,
LanguageRepository,
LoggerService,

View File

@@ -21,9 +21,11 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { InvitationRepository } from '../repositories/invitation.repository';
import { ModelRepository } from '../repositories/model.repository';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { ModelModel, Model as ModelSchema } from '../schemas/model.schema';
import {
Permission,
@@ -46,12 +48,18 @@ describe('PermissionService', () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installPermissionFixtures),
MongooseModule.forFeature([ModelModel, PermissionModel, RoleModel]),
MongooseModule.forFeature([
ModelModel,
PermissionModel,
RoleModel,
InvitationModel,
]),
],
providers: [
ModelRepository,
PermissionService,
RoleRepository,
InvitationRepository,
PermissionRepository,
EventEmitter2,
{

View File

@@ -17,9 +17,11 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { InvitationRepository } from '../repositories/invitation.repository';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { Permission, PermissionModel } from '../schemas/permission.schema';
import { Role, RoleFull, RoleModel } from '../schemas/role.schema';
import { User, UserModel } from '../schemas/user.schema';
@@ -40,12 +42,18 @@ describe('RoleService', () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
rootMongooseTestModule(installPermissionFixtures),
MongooseModule.forFeature([UserModel, PermissionModel, RoleModel]),
MongooseModule.forFeature([
UserModel,
PermissionModel,
RoleModel,
InvitationModel,
]),
],
providers: [
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
PermissionRepository,
EventEmitter2,
],

View File

@@ -24,9 +24,11 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { InvitationRepository } from '../repositories/invitation.repository';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { Role, RoleModel } from '../schemas/role.schema';
import { User, UserFull, UserModel } from '../schemas/user.schema';
@@ -61,6 +63,7 @@ describe('UserService', () => {
UserModel,
PermissionModel,
RoleModel,
InvitationModel,
AttachmentModel,
]),
],
@@ -73,6 +76,7 @@ describe('UserService', () => {
PermissionService,
RoleService,
RoleRepository,
InvitationRepository,
PermissionRepository,
EventEmitter2,
{

View File

@@ -29,8 +29,10 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { InvitationRepository } from '../repositories/invitation.repository';
import { RoleRepository } from '../repositories/role.repository';
import { UserRepository } from '../repositories/user.repository';
import { InvitationModel } from '../schemas/invitation.schema';
import { PermissionModel } from '../schemas/permission.schema';
import { RoleModel } from '../schemas/role.schema';
import { UserModel } from '../schemas/user.schema';
@@ -53,6 +55,7 @@ describe('ValidateAccountService', () => {
UserModel,
RoleModel,
PermissionModel,
InvitationModel,
AttachmentModel,
LanguageModel,
]),
@@ -65,6 +68,7 @@ describe('ValidateAccountService', () => {
UserRepository,
RoleService,
RoleRepository,
InvitationRepository,
LanguageService,
LanguageRepository,
LoggerService,

View File

@@ -35,6 +35,7 @@ export const blocks: TBlockFixtures['values'][] = [
{
name: 'hasNextBlocks',
patterns: ['Hi'],
outcomes: [],
category: null,
options: {
typing: 0,
@@ -53,6 +54,7 @@ export const blocks: TBlockFixtures['values'][] = [
{
name: 'hasPreviousBlocks',
patterns: ['colors'],
outcomes: [],
category: null,
options: {
typing: 0,
@@ -90,6 +92,7 @@ export const blocks: TBlockFixtures['values'][] = [
{
name: 'buttons',
patterns: ['about'],
outcomes: [],
category: null,
options: {
typing: 0,
@@ -127,6 +130,7 @@ export const blocks: TBlockFixtures['values'][] = [
{
name: 'attachment',
patterns: ['image'],
outcomes: [],
category: null,
options: {
typing: 0,
@@ -153,6 +157,7 @@ export const blocks: TBlockFixtures['values'][] = [
{
name: 'test',
patterns: ['yes'],
outcomes: [],
category: null,
//to be verified
options: {

View File

@@ -43,6 +43,17 @@ export const labels: TLabelFixtures['values'][] = [
name: 'TEST_TITLE_2',
title: 'test title 2',
},
{
description: 'test description 3',
label_id: {
messenger: 'messenger',
web: 'web',
twitter: 'twitter',
dimelo: 'dimelo',
},
name: 'TEST_TITLE_3',
title: 'test title 3',
},
];
export const labelFixtures = getFixturesWithDefaultValues<

View File

@@ -12,12 +12,9 @@ import {
} from '@/channel/lib/__test__/label.mock';
import { BlockFull } from '@/chat/schemas/block.schema';
import { FileType } from '@/chat/schemas/types/attachment';
import { ButtonType } from '@/chat/schemas/types/button';
import { ButtonType, PayloadType } from '@/chat/schemas/types/button';
import { CaptureVar } from '@/chat/schemas/types/capture-var';
import {
OutgoingMessageFormat,
PayloadType,
} from '@/chat/schemas/types/message';
import { OutgoingMessageFormat } from '@/chat/schemas/types/message';
import { BlockOptions, ContentOptions } from '@/chat/schemas/types/options';
import { Pattern } from '@/chat/schemas/types/pattern';
import { QuickReplyType } from '@/chat/schemas/types/quick-reply';