mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
Merge pull request #970 from Hexastack/revert-969-revert-965-964-issue---carousel-show-more-issue
Handle context update conflicts
This commit is contained in:
commit
95184734a3
@ -205,6 +205,14 @@ export class BotService {
|
|||||||
block.attachedBlock,
|
block.attachedBlock,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.conversationService.storeContextData(
|
||||||
|
convo,
|
||||||
|
nextBlock,
|
||||||
|
event,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
return await this.triggerBlock(event, convo, nextBlock, fallback);
|
return await this.triggerBlock(event, convo, nextBlock, fallback);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
@ -175,9 +175,15 @@ export class ConversationService extends BaseService<
|
|||||||
const criteria =
|
const criteria =
|
||||||
typeof convo.sender === 'object' ? convo.sender.id : convo.sender;
|
typeof convo.sender === 'object' ? convo.sender.id : convo.sender;
|
||||||
|
|
||||||
await this.subscriberService.updateOne(criteria, {
|
await this.subscriberService.updateOne(
|
||||||
context: profile.context,
|
criteria,
|
||||||
});
|
{
|
||||||
|
context: profile.context,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldFlatten: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return updatedConversation;
|
return updatedConversation;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -12,10 +12,18 @@ import { Model, Types } from 'mongoose';
|
|||||||
import { DummyRepository } from '@/utils/test/dummy/repositories/dummy.repository';
|
import { DummyRepository } from '@/utils/test/dummy/repositories/dummy.repository';
|
||||||
import { closeInMongodConnection } from '@/utils/test/test';
|
import { closeInMongodConnection } from '@/utils/test/test';
|
||||||
|
|
||||||
|
import { flatten } from '../helpers/flatten';
|
||||||
import { DummyModule } from '../test/dummy/dummy.module';
|
import { DummyModule } from '../test/dummy/dummy.module';
|
||||||
import { Dummy } from '../test/dummy/schemas/dummy.schema';
|
import { Dummy } from '../test/dummy/schemas/dummy.schema';
|
||||||
import { buildTestingMocks } from '../test/utils';
|
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', () => {
|
describe('BaseRepository', () => {
|
||||||
let dummyModel: Model<Dummy>;
|
let dummyModel: Model<Dummy>;
|
||||||
let dummyRepository: DummyRepository;
|
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 () => {
|
it('should update by id and invoke lifecycle hooks', async () => {
|
||||||
const created = await dummyRepository.create({ dummy: 'initial text' });
|
const created = await dummyRepository.create({ dummy: 'initial text' });
|
||||||
const mockUpdate = { dummy: 'updated dummy text' };
|
const mockUpdate = { dummy: 'updated dummy text' };
|
||||||
|
@ -21,7 +21,6 @@ import {
|
|||||||
Model,
|
Model,
|
||||||
ProjectionType,
|
ProjectionType,
|
||||||
Query,
|
Query,
|
||||||
QueryOptions,
|
|
||||||
SortOrder,
|
SortOrder,
|
||||||
UpdateQuery,
|
UpdateQuery,
|
||||||
UpdateWithAggregationPipeline,
|
UpdateWithAggregationPipeline,
|
||||||
@ -29,8 +28,13 @@ import {
|
|||||||
} from 'mongoose';
|
} from 'mongoose';
|
||||||
|
|
||||||
import { LoggerService } from '@/logger/logger.service';
|
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 { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto';
|
||||||
import { DtoAction, DtoConfig, DtoInfer } from '../types/dto.types';
|
import { DtoAction, DtoConfig, DtoInfer } from '../types/dto.types';
|
||||||
|
|
||||||
@ -495,18 +499,20 @@ export abstract class BaseRepository<
|
|||||||
async updateOne<D extends Partial<U>>(
|
async updateOne<D extends Partial<U>>(
|
||||||
criteria: string | TFilterQuery<T>,
|
criteria: string | TFilterQuery<T>,
|
||||||
dto: UpdateQuery<DtoInfer<DtoAction.Update, Dto, D>>,
|
dto: UpdateQuery<DtoInfer<DtoAction.Update, Dto, D>>,
|
||||||
options: QueryOptions<D> | null = {
|
options?: TQueryOptions<D>,
|
||||||
new: true,
|
|
||||||
},
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const { shouldFlatten, ...rest } = {
|
||||||
|
new: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
const query = this.model.findOneAndUpdate<T>(
|
const query = this.model.findOneAndUpdate<T>(
|
||||||
{
|
{
|
||||||
...(typeof criteria === 'string' ? { _id: criteria } : criteria),
|
...(typeof criteria === 'string' ? { _id: criteria } : criteria),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$set: dto,
|
$set: shouldFlatten ? flatten(dto) : dto,
|
||||||
},
|
},
|
||||||
options,
|
rest,
|
||||||
);
|
);
|
||||||
const filterCriteria = query.getFilter();
|
const filterCriteria = query.getFilter();
|
||||||
const queryUpdates = query.getUpdate();
|
const queryUpdates = query.getUpdate();
|
||||||
@ -541,9 +547,10 @@ export abstract class BaseRepository<
|
|||||||
async updateMany<D extends Partial<U>>(
|
async updateMany<D extends Partial<U>>(
|
||||||
filter: TFilterQuery<T>,
|
filter: TFilterQuery<T>,
|
||||||
dto: UpdateQuery<D>,
|
dto: UpdateQuery<D>,
|
||||||
|
options?: TFlattenOption,
|
||||||
): Promise<UpdateWriteOpResult> {
|
): Promise<UpdateWriteOpResult> {
|
||||||
return await this.model.updateMany<T>(filter, {
|
return await this.model.updateMany<T>(filter, {
|
||||||
$set: dto,
|
$set: options?.shouldFlatten ? flatten(dto) : dto,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,10 +9,14 @@
|
|||||||
import { ConflictException, Inject } from '@nestjs/common';
|
import { ConflictException, Inject } from '@nestjs/common';
|
||||||
import { ClassTransformOptions } from 'class-transformer';
|
import { ClassTransformOptions } from 'class-transformer';
|
||||||
import { MongoError } from 'mongodb';
|
import { MongoError } from 'mongodb';
|
||||||
import { ProjectionType, QueryOptions } from 'mongoose';
|
import { ProjectionType } from 'mongoose';
|
||||||
|
|
||||||
import { LoggerService } from '@/logger/logger.service';
|
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 { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto';
|
||||||
import { DtoAction, DtoConfig, DtoInfer } from '../types/dto.types';
|
import { DtoAction, DtoConfig, DtoInfer } from '../types/dto.types';
|
||||||
@ -188,13 +192,17 @@ export abstract class BaseService<
|
|||||||
async updateOne(
|
async updateOne(
|
||||||
criteria: string | TFilterQuery<T>,
|
criteria: string | TFilterQuery<T>,
|
||||||
dto: DtoInfer<DtoAction.Update, Dto, Partial<U>>,
|
dto: DtoInfer<DtoAction.Update, Dto, Partial<U>>,
|
||||||
options?: QueryOptions<Partial<U>> | null,
|
options?: TQueryOptions<Partial<U>>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return await this.repository.updateOne(criteria, dto, options);
|
return await this.repository.updateOne(criteria, dto, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMany(filter: TFilterQuery<T>, dto: Partial<U>) {
|
async updateMany(
|
||||||
return await this.repository.updateMany(filter, dto);
|
filter: TFilterQuery<T>,
|
||||||
|
dto: Partial<U>,
|
||||||
|
options?: TFlattenOption,
|
||||||
|
) {
|
||||||
|
return await this.repository.updateMany(filter, dto, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteOne(criteria: string | TFilterQuery<T>) {
|
async deleteOne(criteria: string | TFilterQuery<T>) {
|
||||||
|
135
api/src/utils/helpers/flatten.spec.ts
Normal file
135
api/src/utils/helpers/flatten.spec.ts
Normal 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!');
|
||||||
|
});
|
||||||
|
});
|
37
api/src/utils/helpers/flatten.ts
Normal file
37
api/src/utils/helpers/flatten.ts
Normal 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;
|
||||||
|
};
|
@ -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:
|
* 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.
|
* 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,
|
required: true,
|
||||||
})
|
})
|
||||||
dummy: string;
|
dummy: string;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: Object,
|
||||||
|
})
|
||||||
|
dynamicField?: Record<string, any> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DummyDocument = THydratedDocument<Dummy>;
|
export type DummyDocument = THydratedDocument<Dummy>;
|
||||||
|
@ -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:
|
* 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.
|
* 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).
|
* 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> = {
|
export type TFilterKeysOfType<T, U> = {
|
||||||
[K in keyof T]: T[K] extends U ? K : never;
|
[K in keyof T]: T[K] extends U ? K : never;
|
||||||
@ -137,3 +142,7 @@ export type TFilterQuery<T, S = TReplaceId<T>> = (
|
|||||||
WithoutGenericAny<RootQuerySelector<S>>;
|
WithoutGenericAny<RootQuerySelector<S>>;
|
||||||
|
|
||||||
export type THydratedDocument<T> = TOmitId<HydratedDocument<T>>;
|
export type THydratedDocument<T> = TOmitId<HydratedDocument<T>>;
|
||||||
|
|
||||||
|
export type TFlattenOption = { shouldFlatten?: boolean };
|
||||||
|
|
||||||
|
export type TQueryOptions<D> = (QueryOptions<D> & TFlattenOption) | null;
|
||||||
|
Loading…
Reference in New Issue
Block a user