Merge pull request #347 from Hexastack/fix/list-attachment

fix: content vs element
This commit is contained in:
Med Marrouchi 2024-11-20 11:33:30 +01:00 committed by GitHub
commit db13d02c96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 79 additions and 81 deletions

View File

@ -120,7 +120,6 @@ export const contentMessage: StdOutgoingListMessage = {
id: '1', id: '1',
entity: 'rank', entity: 'rank',
title: 'First', title: 'First',
// @ts-expect-error Necessary workaround
desc: 'About being first', desc: 'About being first',
thumbnail: { thumbnail: {
payload: attachmentWithUrl, payload: attachmentWithUrl,
@ -136,7 +135,6 @@ export const contentMessage: StdOutgoingListMessage = {
id: '2', id: '2',
entity: 'rank', entity: 'rank',
title: 'Second', title: 'Second',
// @ts-expect-error Necessary workaround
desc: 'About being second', desc: 'About being second',
thumbnail: { thumbnail: {
payload: attachmentWithUrl, payload: attachmentWithUrl,

View File

@ -7,7 +7,6 @@
*/ */
import { Attachment } from '@/attachment/schemas/attachment.schema'; import { Attachment } from '@/attachment/schemas/attachment.schema';
import { Content } from '@/cms/schemas/content.schema';
import { Message } from '../message.schema'; import { Message } from '../message.schema';
@ -84,9 +83,14 @@ export type StdOutgoingButtonsMessage = {
buttons: Button[]; buttons: Button[];
}; };
export type ContentElement = { id: string; title: string } & Record<
string,
any
>;
export type StdOutgoingListMessage = { export type StdOutgoingListMessage = {
options: ContentOptions; options: ContentOptions;
elements: Content[]; elements: ContentElement[];
pagination: { pagination: {
total: number; total: number;
skip: number; skip: number;

View File

@ -442,9 +442,7 @@ describe('BlockService', () => {
{ status: true, entity: contentType.id }, { status: true, entity: contentType.id },
{ skip: 0, limit: 2, sort: ['createdAt', 'desc'] }, { skip: 0, limit: 2, sort: ['createdAt', 'desc'] },
); );
const flattenedElements = elements.map((element) => const flattenedElements = elements.map(Content.toElement);
Content.flatDynamicFields(element),
);
expect(result.format).toEqualPayload( expect(result.format).toEqualPayload(
blockProductListMock.options.content?.display, blockProductListMock.options.content?.display,
); );
@ -476,9 +474,7 @@ describe('BlockService', () => {
{ status: true, entity: contentType.id }, { status: true, entity: contentType.id },
{ skip: 2, limit: 2, sort: ['createdAt', 'desc'] }, { skip: 2, limit: 2, sort: ['createdAt', 'desc'] },
); );
const flattenedElements = elements.map((element) => const flattenedElements = elements.map(Content.toElement);
Content.flatDynamicFields(element),
);
expect(result.format).toEqual( expect(result.format).toEqual(
blockProductListMock.options.content?.display, blockProductListMock.options.content?.display,
); );

View File

@ -6,10 +6,11 @@
* 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 { Prop, Schema, SchemaFactory, ModelDefinition } from '@nestjs/mongoose'; import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import mongoose, { Document } from 'mongoose'; import mongoose, { Document } from 'mongoose';
import { ContentElement } from '@/chat/schemas/types/message';
import { config } from '@/config'; import { config } from '@/config';
import { BaseSchema } from '@/utils/generics/base-schema'; import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@ -51,14 +52,14 @@ export class ContentStub extends BaseSchema {
/** /**
* Helper to return the internal url of this content. * Helper to return the internal url of this content.
*/ */
static getUrl(item: Content): string { static getUrl(item: ContentElement): string {
return new URL('/content/view/' + item.id, config.apiPath).toString(); return new URL('/content/view/' + item.id, config.apiPath).toString();
} }
/** /**
* Helper that returns the relative chatbot payload for this content. * Helper that returns the relative chatbot payload for this content.
*/ */
static getPayload(item: Content): string { static getPayload(item: ContentElement): string {
return 'postback' in item ? (item.postback as string) : item.title; return 'postback' in item ? (item.postback as string) : item.title;
} }
} }
@ -68,12 +69,18 @@ export class Content extends ContentStub {
@Transform(({ obj }) => obj.entity.toString()) @Transform(({ obj }) => obj.entity.toString())
entity: string; entity: string;
static flatDynamicFields(element: Content) { /**
Object.entries(element.dynamicFields).forEach(([key, value]) => { * Converts a content object to an element (A flat representation of a content)
element[key] = value; *
}); * @param content
element.dynamicFields = undefined; * @returns An object that has all dynamic fields accessible at top level
return element; */
static toElement(content: Content): ContentElement {
return {
id: content.id,
title: content.title,
...content.dynamicFields,
};
} }
} }

View File

@ -14,7 +14,10 @@ import { AttachmentRepository } from '@/attachment/repositories/attachment.repos
import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service'; import { AttachmentService } from '@/attachment/services/attachment.service';
import { FileType } from '@/chat/schemas/types/attachment'; import { FileType } from '@/chat/schemas/types/attachment';
import { OutgoingMessageFormat } from '@/chat/schemas/types/message'; import {
ContentElement,
OutgoingMessageFormat,
} from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options'; import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants'; import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
@ -159,7 +162,10 @@ describe('ContentService', () => {
}, },
]; ];
it('should return all content attachment ids', () => { it('should return all content attachment ids', () => {
const result = contentService.getAttachmentIds(contents, 'image'); const result = contentService.getAttachmentIds(
contents.map(Content.toElement),
'image',
);
expect(result).toEqual(['123', '456']); expect(result).toEqual(['123', '456']);
}); });
@ -172,22 +178,19 @@ describe('ContentService', () => {
describe('populateAttachments', () => { describe('populateAttachments', () => {
it('should return populated content', async () => { it('should return populated content', async () => {
const storeContents = await contentService.find({ title: /^store/ }); const storeContents = await contentService.find({ title: /^store/ });
const populatedStoreContents: Content[] = await Promise.all( const elements: ContentElement[] = await Promise.all(
storeContents.map(async (store) => { storeContents.map(Content.toElement).map(async (store) => {
const attachmentId = store.dynamicFields.image.payload.attachment_id; const attachmentId = store.image.payload.attachment_id;
if (attachmentId) { if (attachmentId) {
const attachment = await attachmentService.findOne(attachmentId); const attachment = await attachmentService.findOne(attachmentId);
if (attachment) { if (attachment) {
return { return {
...store, ...store,
dynamicFields: { image: {
...store.dynamicFields, type: 'image',
image: { payload: {
type: 'image', ...attachment,
payload: { url: `http://localhost:4000/attachment/download/${attachment.id}/${attachment.name}`,
...attachment,
url: `http://localhost:4000/attachment/download/${attachment.id}/${attachment.name}`,
},
}, },
}, },
}; };
@ -197,10 +200,10 @@ describe('ContentService', () => {
}), }),
); );
const result = await contentService.populateAttachments( const result = await contentService.populateAttachments(
storeContents, storeContents.map(Content.toElement),
'image', 'image',
); );
expect(result).toEqualPayload(populatedStoreContents); expect(result).toEqualPayload(elements);
}); });
}); });
@ -221,9 +224,7 @@ describe('ContentService', () => {
{ status: true }, { status: true },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] }, { skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
); );
const flattenedElements = actualData.map((content) => const flattenedElements = actualData.map(Content.toElement);
Content.flatDynamicFields(content),
);
const content = await contentService.getContent(contentOptions, 0); const content = await contentService.getContent(contentOptions, 0);
expect(content?.elements).toEqualPayload(flattenedElements, [ expect(content?.elements).toEqualPayload(flattenedElements, [
...IGNORED_TEST_FIELDS, ...IGNORED_TEST_FIELDS,
@ -237,9 +238,7 @@ describe('ContentService', () => {
{ status: true, entity: contentType.id }, { status: true, entity: contentType.id },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] }, { skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
); );
const flattenedElements = actualData.map((content) => const flattenedElements = actualData.map(Content.toElement);
Content.flatDynamicFields(content),
);
const content = await contentService.getContent( const content = await contentService.getContent(
{ {
...contentOptions, ...contentOptions,
@ -257,9 +256,7 @@ describe('ContentService', () => {
{ status: true, entity: contentType.id, title: /^Jean/ }, { status: true, entity: contentType.id, title: /^Jean/ },
{ skip: 0, limit: 10, sort: ['createdAt', 'desc'] }, { skip: 0, limit: 10, sort: ['createdAt', 'desc'] },
); );
const flattenedElements = actualData.map((content) => const flattenedElements = actualData.map(Content.toElement);
Content.flatDynamicFields(content),
);
const content = await contentService.getContent( const content = await contentService.getContent(
{ {
...contentOptions, ...contentOptions,
@ -276,9 +273,7 @@ describe('ContentService', () => {
{ status: true }, { status: true },
{ skip: 2, limit: 2, sort: ['createdAt', 'desc'] }, { skip: 2, limit: 2, sort: ['createdAt', 'desc'] },
); );
const flattenedElements = actualData.map((content) => const flattenedElements = actualData.map(Content.toElement);
Content.flatDynamicFields(content),
);
const content = await contentService.getContent( const content = await contentService.getContent(
{ {
...contentOptions, ...contentOptions,

View File

@ -11,7 +11,10 @@ import { Injectable } from '@nestjs/common';
import { Attachment } from '@/attachment/schemas/attachment.schema'; import { Attachment } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service'; import { AttachmentService } from '@/attachment/services/attachment.service';
import { WithUrl } from '@/chat/schemas/types/attachment'; import { WithUrl } from '@/chat/schemas/types/attachment';
import { StdOutgoingListMessage } from '@/chat/schemas/types/message'; import {
ContentElement,
StdOutgoingListMessage,
} from '@/chat/schemas/types/message';
import { ContentOptions } from '@/chat/schemas/types/options'; import { ContentOptions } from '@/chat/schemas/types/options';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { BaseService } from '@/utils/generics/base-service'; import { BaseService } from '@/utils/generics/base-service';
@ -57,10 +60,10 @@ export class ContentService extends BaseService<
* *
* @return A list of attachment IDs. * @return A list of attachment IDs.
*/ */
getAttachmentIds(contents: Content[], attachmentFieldName: string) { getAttachmentIds(contents: ContentElement[], attachmentFieldName: string) {
return contents.reduce((acc, content) => { return contents.reduce((acc, content) => {
if (attachmentFieldName in content.dynamicFields) { if (attachmentFieldName in content) {
const attachment = content.dynamicFields[attachmentFieldName]; const attachment = content[attachmentFieldName];
if ( if (
typeof attachment === 'object' && typeof attachment === 'object' &&
@ -84,16 +87,16 @@ export class ContentService extends BaseService<
/** /**
* Populates attachment fields within content entities with detailed attachment information. * Populates attachment fields within content entities with detailed attachment information.
* *
* @param contents - An array of content entities. * @param elements - An array of content entities.
* @param attachmentFieldName - The name of the attachment field to populate. * @param attachmentFieldName - The name of the attachment field to populate.
* *
* @return A list of content with populated attachment data. * @return A list of content with populated attachment data.
*/ */
async populateAttachments( async populateAttachments(
contents: Content[], elements: ContentElement[],
attachmentFieldName: string, attachmentFieldName: string,
): Promise<Content[]> { ): Promise<ContentElement[]> {
const attachmentIds = this.getAttachmentIds(contents, attachmentFieldName); const attachmentIds = this.getAttachmentIds(elements, attachmentFieldName);
if (attachmentIds.length > 0) { if (attachmentIds.length > 0) {
const attachments = await this.attachmentService.find({ const attachments = await this.attachmentService.find({
@ -107,8 +110,8 @@ export class ContentService extends BaseService<
}, },
{} as { [key: string]: WithUrl<Attachment> }, {} as { [key: string]: WithUrl<Attachment> },
); );
const populatedContents = contents.map((content) => { const populatedContents = elements.map((content) => {
const attachmentField = content.dynamicFields[attachmentFieldName]; const attachmentField = content[attachmentFieldName];
if ( if (
typeof attachmentField === 'object' && typeof attachmentField === 'object' &&
'attachment_id' in attachmentField.payload 'attachment_id' in attachmentField.payload
@ -116,17 +119,14 @@ export class ContentService extends BaseService<
const attachmentId = attachmentField?.payload?.attachment_id; const attachmentId = attachmentField?.payload?.attachment_id;
return { return {
...content, ...content,
dynamicFields: { [attachmentFieldName]: {
...content.dynamicFields, type: attachmentField.type,
[attachmentFieldName]: { payload: {
type: attachmentField.type, ...(attachmentsById[attachmentId] || attachmentField.payload),
payload: { url: Attachment.getAttachmentUrl(
...(attachmentsById[attachmentId] || attachmentField.payload), attachmentId,
url: Attachment.getAttachmentUrl( attachmentsById[attachmentId].name,
attachmentId, ),
attachmentsById[attachmentId].name,
),
},
}, },
}, },
}; };
@ -136,7 +136,7 @@ export class ContentService extends BaseService<
}); });
return populatedContents; return populatedContents;
} }
return contents; return elements;
} }
/** /**
@ -175,21 +175,15 @@ export class ContentService extends BaseService<
limit, limit,
sort: ['createdAt', 'desc'], sort: ['createdAt', 'desc'],
}); });
const elements = contents.map(Content.toElement);
const attachmentFieldName = options.fields.image_url; const attachmentFieldName = options.fields.image_url;
if (attachmentFieldName) { if (attachmentFieldName) {
// Populate attachment when there's an image field // Populate attachment when there's an image field
const populatedContents = await this.populateAttachments(
contents,
attachmentFieldName,
);
const flatContent = populatedContents.map((content) => ({
...content,
...content.dynamicFields,
dynamicFields: undefined,
}));
return { return {
elements: flatContent, elements: await this.populateAttachments(
elements,
attachmentFieldName,
),
pagination: { pagination: {
total, total,
skip, skip,
@ -198,7 +192,7 @@ export class ContentService extends BaseService<
}; };
} }
return { return {
elements: contents, elements,
pagination: { pagination: {
total, total,
skip, skip,

View File

@ -31,6 +31,7 @@ import { WithUrl } from '@/chat/schemas/types/attachment';
import { Button, ButtonType } from '@/chat/schemas/types/button'; import { Button, ButtonType } from '@/chat/schemas/types/button';
import { import {
AnyMessage, AnyMessage,
ContentElement,
FileType, FileType,
IncomingMessage, IncomingMessage,
OutgoingMessage, OutgoingMessage,
@ -963,7 +964,10 @@ export default abstract class BaseWebChannelHandler<
* *
* @returns An array of elements object * @returns An array of elements object
*/ */
_formatElements(data: any[], options: BlockOptions): Web.MessageElement[] { _formatElements(
data: ContentElement[],
options: BlockOptions,
): Web.MessageElement[] {
if (!options.content || !options.content.fields) { if (!options.content || !options.content.fields) {
throw new Error('Content options are missing the fields'); throw new Error('Content options are missing the fields');
} }