Merge pull request #970 from Hexastack/revert-969-revert-965-964-issue---carousel-show-more-issue
Some checks are pending
Build and Push Docker API Image / build-and-push (push) Waiting to run
Build and Push Docker Base Image / build-and-push (push) Waiting to run
Build and Push Docker UI Image / build-and-push (push) Waiting to run

Handle context update conflicts
This commit is contained in:
Med Marrouchi 2025-05-06 16:39:22 +01:00 committed by GitHub
commit 95184734a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 268 additions and 19 deletions

View File

@ -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(

View File

@ -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) {

View File

@ -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<Dummy, keyof BaseSchema>;
describe('BaseRepository', () => {
let dummyModel: Model<Dummy>;
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' };

View File

@ -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<D extends Partial<U>>(
criteria: string | TFilterQuery<T>,
dto: UpdateQuery<DtoInfer<DtoAction.Update, Dto, D>>,
options: QueryOptions<D> | null = {
new: true,
},
options?: TQueryOptions<D>,
): Promise<T> {
const { shouldFlatten, ...rest } = {
new: true,
...options,
};
const query = this.model.findOneAndUpdate<T>(
{
...(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<D extends Partial<U>>(
filter: TFilterQuery<T>,
dto: UpdateQuery<D>,
options?: TFlattenOption,
): Promise<UpdateWriteOpResult> {
return await this.model.updateMany<T>(filter, {
$set: dto,
$set: options?.shouldFlatten ? flatten(dto) : dto,
});
}

View File

@ -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<T>,
dto: DtoInfer<DtoAction.Update, Dto, Partial<U>>,
options?: QueryOptions<Partial<U>> | null,
options?: TQueryOptions<Partial<U>>,
): Promise<T> {
return await this.repository.updateOne(criteria, dto, options);
}
async updateMany(filter: TFilterQuery<T>, dto: Partial<U>) {
return await this.repository.updateMany(filter, dto);
async updateMany(
filter: TFilterQuery<T>,
dto: Partial<U>,
options?: TFlattenOption,
) {
return await this.repository.updateMany(filter, dto, options);
}
async deleteOne(criteria: string | TFilterQuery<T>) {

View File

@ -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!');
});
});

View File

@ -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;
};

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.
@ -19,6 +19,11 @@ export class Dummy extends BaseSchema {
required: true,
})
dummy: string;
@Prop({
type: Object,
})
dynamicField?: Record<string, any> | undefined;
}
export type DummyDocument = THydratedDocument<Dummy>;

View File

@ -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<T, U> = {
[K in keyof T]: T[K] extends U ? K : never;
@ -137,3 +142,7 @@ export type TFilterQuery<T, S = TReplaceId<T>> = (
WithoutGenericAny<RootQuerySelector<S>>;
export type THydratedDocument<T> = TOmitId<HydratedDocument<T>>;
export type TFlattenOption = { shouldFlatten?: boolean };
export type TQueryOptions<D> = (QueryOptions<D> & TFlattenOption) | null;