diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 13e6cd57..17bee1fd 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -205,6 +205,14 @@ export class BotService { block.attachedBlock, ); } + + await this.conversationService.storeContextData( + convo, + nextBlock, + event, + false, + ); + return await this.triggerBlock(event, convo, nextBlock, fallback); } else { this.logger.warn( diff --git a/api/src/chat/services/conversation.service.ts b/api/src/chat/services/conversation.service.ts index a682efb8..332a64d2 100644 --- a/api/src/chat/services/conversation.service.ts +++ b/api/src/chat/services/conversation.service.ts @@ -175,9 +175,15 @@ export class ConversationService extends BaseService< const criteria = typeof convo.sender === 'object' ? convo.sender.id : convo.sender; - await this.subscriberService.updateOne(criteria, { - context: profile.context, - }); + await this.subscriberService.updateOne( + criteria, + { + context: profile.context, + }, + { + shouldFlatten: true, + }, + ); return updatedConversation; } catch (err) { diff --git a/api/src/utils/generics/base-repository.spec.ts b/api/src/utils/generics/base-repository.spec.ts index 95d19fdb..a11a1ae4 100644 --- a/api/src/utils/generics/base-repository.spec.ts +++ b/api/src/utils/generics/base-repository.spec.ts @@ -12,10 +12,18 @@ import { Model, Types } from 'mongoose'; import { DummyRepository } from '@/utils/test/dummy/repositories/dummy.repository'; import { closeInMongodConnection } from '@/utils/test/test'; +import { flatten } from '../helpers/flatten'; import { DummyModule } from '../test/dummy/dummy.module'; import { Dummy } from '../test/dummy/schemas/dummy.schema'; import { buildTestingMocks } from '../test/utils'; +import { BaseSchema } from './base-schema'; + +const FLATTEN_PAYLOAD = { + dummy: 'updated dummy text', + dynamicField: { field1: 'value1', field2: 'value2' }, +} as const satisfies Omit; + describe('BaseRepository', () => { let dummyModel: Model; let dummyRepository: DummyRepository; @@ -140,6 +148,32 @@ describe('BaseRepository', () => { }); }); + it('should update and flatten by id and return one dummy data', async () => { + jest.spyOn(dummyModel, 'findOneAndUpdate'); + const result = await dummyRepository.updateOne( + createdId, + FLATTEN_PAYLOAD, + { + new: true, + shouldFlatten: true, + }, + ); + + expect(dummyModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: createdId }, + { + $set: flatten(FLATTEN_PAYLOAD), + }, + { + new: true, + }, + ); + expect(result).toEqualPayload({ + dummy: 'updated dummy text', + dynamicField: { field1: 'value1', field2: 'value2' }, + }); + }); + it('should update by id and invoke lifecycle hooks', async () => { const created = await dummyRepository.create({ dummy: 'initial text' }); const mockUpdate = { dummy: 'updated dummy text' }; diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 38834083..16672835 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -21,7 +21,6 @@ import { Model, ProjectionType, Query, - QueryOptions, SortOrder, UpdateQuery, UpdateWithAggregationPipeline, @@ -29,8 +28,13 @@ import { } from 'mongoose'; import { LoggerService } from '@/logger/logger.service'; -import { TFilterQuery } from '@/utils/types/filter.types'; +import { + TFilterQuery, + TFlattenOption, + TQueryOptions, +} from '@/utils/types/filter.types'; +import { flatten } from '../helpers/flatten'; import { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto'; import { DtoAction, DtoConfig, DtoInfer } from '../types/dto.types'; @@ -495,18 +499,20 @@ export abstract class BaseRepository< async updateOne>( criteria: string | TFilterQuery, dto: UpdateQuery>, - options: QueryOptions | null = { - new: true, - }, + options?: TQueryOptions, ): Promise { + const { shouldFlatten, ...rest } = { + new: true, + ...options, + }; const query = this.model.findOneAndUpdate( { ...(typeof criteria === 'string' ? { _id: criteria } : criteria), }, { - $set: dto, + $set: shouldFlatten ? flatten(dto) : dto, }, - options, + rest, ); const filterCriteria = query.getFilter(); const queryUpdates = query.getUpdate(); @@ -541,9 +547,10 @@ export abstract class BaseRepository< async updateMany>( filter: TFilterQuery, dto: UpdateQuery, + options?: TFlattenOption, ): Promise { return await this.model.updateMany(filter, { - $set: dto, + $set: options?.shouldFlatten ? flatten(dto) : dto, }); } diff --git a/api/src/utils/generics/base-service.ts b/api/src/utils/generics/base-service.ts index a4a004c1..6f0b88af 100644 --- a/api/src/utils/generics/base-service.ts +++ b/api/src/utils/generics/base-service.ts @@ -9,10 +9,14 @@ import { ConflictException, Inject } from '@nestjs/common'; import { ClassTransformOptions } from 'class-transformer'; import { MongoError } from 'mongodb'; -import { ProjectionType, QueryOptions } from 'mongoose'; +import { ProjectionType } from 'mongoose'; import { LoggerService } from '@/logger/logger.service'; -import { TFilterQuery } from '@/utils/types/filter.types'; +import { + TFilterQuery, + TFlattenOption, + TQueryOptions, +} from '@/utils/types/filter.types'; import { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto'; import { DtoAction, DtoConfig, DtoInfer } from '../types/dto.types'; @@ -188,13 +192,17 @@ export abstract class BaseService< async updateOne( criteria: string | TFilterQuery, dto: DtoInfer>, - options?: QueryOptions> | null, + options?: TQueryOptions>, ): Promise { return await this.repository.updateOne(criteria, dto, options); } - async updateMany(filter: TFilterQuery, dto: Partial) { - return await this.repository.updateMany(filter, dto); + async updateMany( + filter: TFilterQuery, + dto: Partial, + options?: TFlattenOption, + ) { + return await this.repository.updateMany(filter, dto, options); } async deleteOne(criteria: string | TFilterQuery) { diff --git a/api/src/utils/helpers/flatten.spec.ts b/api/src/utils/helpers/flatten.spec.ts new file mode 100644 index 00000000..c04bf011 --- /dev/null +++ b/api/src/utils/helpers/flatten.spec.ts @@ -0,0 +1,135 @@ +/* + * 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 { flatten } from './flatten'; + +describe('flatten', () => { + it('should support a nested object with one nested level', () => { + const object = { + name: 'name', + context: { nestedField: 'value' }, + }; + const result = flatten(object); + + expect(result).toStrictEqual({ + name: 'name', + 'context.nestedField': 'value', + }); + }); + + it('should support a nested object with multiple nested levels', () => { + const object = { + name: 'name', + context: { + nestedField: 'value', + country: { + isoCode: 'usa', + phonePrefix: '+1', + countryName: 'United States', + }, + }, + }; + const result = flatten(object); + + expect(result).toStrictEqual({ + name: 'name', + 'context.nestedField': 'value', + 'context.country.isoCode': 'usa', + 'context.country.phonePrefix': '+1', + 'context.country.countryName': 'United States', + }); + }); + + it('should support object with flattened keys', () => { + const object = { + 'user.name': { + id: 'Alice', + }, + nested: { + 'country.name': 'France', + }, + }; + const result = flatten(object); + + expect(result).toStrictEqual({ + 'nested.country.name': 'France', + 'user.name.id': 'Alice', + }); + }); + + it('should support custom prefix', () => { + const object = { + isoCode: 'tun', + phonePrefix: '+216', + countryName: 'Tunisia', + }; + const result = flatten(object, 'context.country'); + + expect(result).toStrictEqual({ + 'context.country.isoCode': 'tun', + 'context.country.phonePrefix': '+216', + 'context.country.countryName': 'Tunisia', + }); + }); + + it('should support custom static initial value', () => { + const object = { + context: { + country: { isoCode: 'fra', phonePrefix: '+33', countryName: 'France' }, + }, + }; + const result = flatten(object, undefined, { language: 'fr' }); + + expect(result).toStrictEqual({ + language: 'fr', + 'context.country.isoCode': 'fra', + 'context.country.phonePrefix': '+33', + 'context.country.countryName': 'France', + }); + }); + + it('should handle arrays as values without recursing into them', () => { + const object = { + items: [1, 2, 3], + nested: { + moreItems: [4, 5, 6], + deepNested: { + evenMoreItems: [7, 8, 9], + }, + }, + }; + const result = flatten(object); + + expect(result).toStrictEqual({ + items: [1, 2, 3], + 'nested.moreItems': [4, 5, 6], + 'nested.deepNested.evenMoreItems': [7, 8, 9], + }); + }); + + it('should support an object without nested object', () => { + const object = { + name: 'name', + }; + const result = flatten(object); + + expect(result).toStrictEqual({ + name: 'name', + }); + }); + + it('should support an empty object', () => { + const result = flatten({}); + + expect(result).toStrictEqual({}); + }); + + it('should throw an error if data is an array', () => { + expect(() => flatten([])).toThrow('Data should be an object!'); + }); +}); diff --git a/api/src/utils/helpers/flatten.ts b/api/src/utils/helpers/flatten.ts new file mode 100644 index 00000000..58ba9941 --- /dev/null +++ b/api/src/utils/helpers/flatten.ts @@ -0,0 +1,37 @@ +/* + * 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). + */ + +/** + * Flattens a nested object into a single-level object with dot-separated keys. + * @param data - The data object to flatten + * @param prefix - The optional base key to prefix to the current object's keys + * @param result - The optional accumulator for the flattened object data + * @returns A new object with flattened keys + * @throws Error if the data is an array + */ +export const flatten = ( + data: object, + prefix: string | undefined = undefined, + result: object = {}, +): object => { + if (Array.isArray(data)) { + throw new Error('Data should be an object!'); + } + + for (const [key, value] of Object.entries(data)) { + const path = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + flatten(value, path, result); + } else { + result[path] = value; + } + } + + return result; +}; diff --git a/api/src/utils/test/dummy/schemas/dummy.schema.ts b/api/src/utils/test/dummy/schemas/dummy.schema.ts index edab8598..fe8acc06 100644 --- a/api/src/utils/test/dummy/schemas/dummy.schema.ts +++ b/api/src/utils/test/dummy/schemas/dummy.schema.ts @@ -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. @@ -19,6 +19,11 @@ export class Dummy extends BaseSchema { required: true, }) dummy: string; + + @Prop({ + type: Object, + }) + dynamicField?: Record | undefined; } export type DummyDocument = THydratedDocument; diff --git a/api/src/utils/types/filter.types.ts b/api/src/utils/types/filter.types.ts index e859d3a7..2c5f2d47 100644 --- a/api/src/utils/types/filter.types.ts +++ b/api/src/utils/types/filter.types.ts @@ -1,12 +1,17 @@ /* - * 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 { HydratedDocument, QuerySelector, RootQuerySelector } from 'mongoose'; +import { + HydratedDocument, + QueryOptions, + QuerySelector, + RootQuerySelector, +} from 'mongoose'; export type TFilterKeysOfType = { [K in keyof T]: T[K] extends U ? K : never; @@ -137,3 +142,7 @@ export type TFilterQuery> = ( WithoutGenericAny>; export type THydratedDocument = TOmitId>; + +export type TFlattenOption = { shouldFlatten?: boolean }; + +export type TQueryOptions = (QueryOptions & TFlattenOption) | null;