mirror of
https://github.com/hexastack/hexabot
synced 2025-04-18 13:28:50 +00:00
fix: enhacement of content import
This commit is contained in:
parent
06c999b7f3
commit
82f46ad65e
@ -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:
|
* 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 fs from 'fs';
|
|
||||||
|
|
||||||
import { NotFoundException } from '@nestjs/common/exceptions';
|
import { NotFoundException } from '@nestjs/common/exceptions';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
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 { LoggerService } from '@/logger/logger.service';
|
||||||
import { NOT_FOUND_ID } from '@/utils/constants/mock';
|
import { NOT_FOUND_ID } from '@/utils/constants/mock';
|
||||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||||
@ -48,10 +40,8 @@ describe('ContentController', () => {
|
|||||||
let contentController: ContentController;
|
let contentController: ContentController;
|
||||||
let contentService: ContentService;
|
let contentService: ContentService;
|
||||||
let contentTypeService: ContentTypeService;
|
let contentTypeService: ContentTypeService;
|
||||||
let attachmentService: AttachmentService;
|
|
||||||
let contentType: ContentType | null;
|
let contentType: ContentType | null;
|
||||||
let content: Content | null;
|
let content: Content | null;
|
||||||
let attachment: Attachment | null;
|
|
||||||
let updatedContent;
|
let updatedContent;
|
||||||
let pageQuery: PageQueryDto<Content>;
|
let pageQuery: PageQueryDto<Content>;
|
||||||
|
|
||||||
@ -60,34 +50,24 @@ describe('ContentController', () => {
|
|||||||
controllers: [ContentController],
|
controllers: [ContentController],
|
||||||
imports: [
|
imports: [
|
||||||
rootMongooseTestModule(installContentFixtures),
|
rootMongooseTestModule(installContentFixtures),
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([ContentTypeModel, ContentModel]),
|
||||||
ContentTypeModel,
|
|
||||||
ContentModel,
|
|
||||||
AttachmentModel,
|
|
||||||
]),
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
LoggerService,
|
LoggerService,
|
||||||
ContentTypeService,
|
ContentTypeService,
|
||||||
ContentService,
|
ContentService,
|
||||||
ContentRepository,
|
ContentRepository,
|
||||||
AttachmentService,
|
|
||||||
ContentTypeRepository,
|
ContentTypeRepository,
|
||||||
AttachmentRepository,
|
|
||||||
EventEmitter2,
|
EventEmitter2,
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
contentController = module.get<ContentController>(ContentController);
|
contentController = module.get<ContentController>(ContentController);
|
||||||
contentService = module.get<ContentService>(ContentService);
|
contentService = module.get<ContentService>(ContentService);
|
||||||
attachmentService = module.get<AttachmentService>(AttachmentService);
|
|
||||||
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
|
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
|
||||||
contentType = await contentTypeService.findOne({ name: 'Product' });
|
contentType = await contentTypeService.findOne({ name: 'Product' });
|
||||||
content = await contentService.findOne({
|
content = await contentService.findOne({
|
||||||
title: 'Jean',
|
title: 'Jean',
|
||||||
});
|
});
|
||||||
attachment = await attachmentService.findOne({
|
|
||||||
name: 'store1.jpg',
|
|
||||||
});
|
|
||||||
|
|
||||||
pageQuery = getPageQuery<Content>({
|
pageQuery = getPageQuery<Content>({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
@ -243,91 +223,76 @@ describe('ContentController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('import', () => {
|
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 () => {
|
it('should import content from a CSV file', async () => {
|
||||||
const mockCsvData: string = `other,title,status,image
|
const mockContentType = {
|
||||||
should not appear,store 3,true,image.jpg`;
|
id: '0',
|
||||||
|
|
||||||
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({
|
|
||||||
name: 'Store',
|
name: 'Store',
|
||||||
});
|
} as unknown as ContentType;
|
||||||
|
jest
|
||||||
const result = await contentController.import({
|
.spyOn(contentTypeService, 'findOne')
|
||||||
idFileToImport: attachment!.id,
|
.mockResolvedValueOnce(mockContentType);
|
||||||
idTargetContentType: contentType!.id,
|
jest.spyOn(contentService, 'parseAndSaveDataset').mockResolvedValueOnce([
|
||||||
});
|
{
|
||||||
expect(contentService.createMany).toHaveBeenCalledWith([
|
entity: mockContentType.id,
|
||||||
{ ...mockCsvContentDto, entity: contentType!.id },
|
title: 'store 3',
|
||||||
|
status: true,
|
||||||
|
dynamicFields: {
|
||||||
|
image: 'image.jpg',
|
||||||
|
},
|
||||||
|
id: '',
|
||||||
|
createdAt: null as unknown as Date,
|
||||||
|
updatedAt: null as unknown as Date,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result).toEqualPayload(
|
const result = await contentController.import(file, mockContentType.id);
|
||||||
[
|
expect(contentService.parseAndSaveDataset).toHaveBeenCalledWith(
|
||||||
{
|
mockCsvData,
|
||||||
...mockCsvContentDto,
|
mockContentType.id,
|
||||||
entity: contentType!.id,
|
mockContentType,
|
||||||
},
|
|
||||||
],
|
|
||||||
[...IGNORED_TEST_FIELDS, 'rag'],
|
|
||||||
);
|
);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
entity: mockContentType.id,
|
||||||
|
title: 'store 3',
|
||||||
|
status: true,
|
||||||
|
dynamicFields: {
|
||||||
|
image: 'image.jpg',
|
||||||
|
},
|
||||||
|
id: '',
|
||||||
|
createdAt: null as unknown as Date,
|
||||||
|
updatedAt: null as unknown as Date,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if content type is not found', async () => {
|
it('should throw NotFoundException if content type is not found', async () => {
|
||||||
|
jest.spyOn(contentTypeService, 'findOne').mockResolvedValueOnce(null);
|
||||||
await expect(
|
await expect(
|
||||||
contentController.import({
|
contentController.import(file, 'INVALID_ID'),
|
||||||
idFileToImport: attachment!.id,
|
|
||||||
idTargetContentType: NOT_FOUND_ID,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(new NotFoundException('Content type is not found'));
|
).rejects.toThrow(new NotFoundException('Content type is not found'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if file is not found in attachment database', async () => {
|
it('should throw NotFoundException if idTargetContentType is missing', async () => {
|
||||||
const contentType = await contentTypeService.findOne({
|
await expect(contentController.import(file, '')).rejects.toThrow(
|
||||||
name: 'Product',
|
new NotFoundException('Missing parameter'),
|
||||||
});
|
);
|
||||||
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 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'));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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:
|
* 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 fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -20,14 +17,12 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UploadedFile,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { BadRequestException } from '@nestjs/common/exceptions';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
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 { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||||
import { LoggerService } from '@/logger/logger.service';
|
import { LoggerService } from '@/logger/logger.service';
|
||||||
import { BaseController } from '@/utils/generics/base-controller';
|
import { BaseController } from '@/utils/generics/base-controller';
|
||||||
@ -59,7 +54,6 @@ export class ContentController extends BaseController<
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly contentService: ContentService,
|
private readonly contentService: ContentService,
|
||||||
private readonly contentTypeService: ContentTypeService,
|
private readonly contentTypeService: ContentTypeService,
|
||||||
private readonly attachmentService: AttachmentService,
|
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {
|
) {
|
||||||
super(contentService);
|
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.
|
* 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 idTargetContentType - The content type to match the CSV data against. *
|
||||||
* @param idFileToImport - The ID of the file to be imported.
|
|
||||||
*
|
|
||||||
* @returns A promise that resolves to the newly created content documents.
|
* @returns A promise that resolves to the newly created content documents.
|
||||||
*/
|
*/
|
||||||
@Get('import/:idTargetContentType/:idFileToImport')
|
@CsrfCheck(true)
|
||||||
|
@Post('import')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async import(
|
async import(
|
||||||
@Param()
|
@UploadedFile() file: Express.Multer.File,
|
||||||
{
|
@Query('idTargetContentType')
|
||||||
idTargetContentType: targetContentType,
|
targetContentType: string,
|
||||||
idFileToImport: fileToImport,
|
|
||||||
}: {
|
|
||||||
idTargetContentType: string;
|
|
||||||
idFileToImport: string;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
// Check params
|
const datasetContent = file.buffer.toString('utf-8');
|
||||||
if (!fileToImport || !targetContentType) {
|
if (!targetContentType) {
|
||||||
this.logger.warn(`Parameters are missing`);
|
this.logger.warn(`Parameter is missing`);
|
||||||
throw new NotFoundException(`Missing params`);
|
throw new NotFoundException(`Missing parameter`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the content type that corresponds to the given content
|
|
||||||
const contentType =
|
const contentType =
|
||||||
await this.contentTypeService.findOne(targetContentType);
|
await this.contentTypeService.findOne(targetContentType);
|
||||||
if (!contentType) {
|
if (!contentType) {
|
||||||
@ -124,56 +111,11 @@ export class ContentController extends BaseController<
|
|||||||
throw new NotFoundException(`Content type is not found`);
|
throw new NotFoundException(`Content type is not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file location
|
return await this.contentService.parseAndSaveDataset(
|
||||||
const file = await this.attachmentService.findOne(fileToImport);
|
datasetContent,
|
||||||
// Check if file is present
|
targetContentType,
|
||||||
const filePath = file
|
contentType,
|
||||||
? 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] }), {}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create content
|
|
||||||
return await this.contentService.createMany(contentsDto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -10,9 +10,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
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 { 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';
|
||||||
@ -44,19 +41,13 @@ describe('ContentService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
rootMongooseTestModule(installContentFixtures),
|
rootMongooseTestModule(installContentFixtures),
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([ContentTypeModel, ContentModel]),
|
||||||
ContentTypeModel,
|
|
||||||
ContentModel,
|
|
||||||
AttachmentModel,
|
|
||||||
]),
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ContentTypeRepository,
|
ContentTypeRepository,
|
||||||
ContentRepository,
|
ContentRepository,
|
||||||
AttachmentRepository,
|
|
||||||
ContentTypeService,
|
ContentTypeService,
|
||||||
ContentService,
|
ContentService,
|
||||||
AttachmentService,
|
|
||||||
LoggerService,
|
LoggerService,
|
||||||
EventEmitter2,
|
EventEmitter2,
|
||||||
],
|
],
|
||||||
|
@ -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).
|
* 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 { 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';
|
||||||
@ -17,6 +17,7 @@ import { TFilterQuery } from '@/utils/types/filter.types';
|
|||||||
|
|
||||||
import { ContentDto } from '../dto/content.dto';
|
import { ContentDto } from '../dto/content.dto';
|
||||||
import { ContentRepository } from '../repositories/content.repository';
|
import { ContentRepository } from '../repositories/content.repository';
|
||||||
|
import { ContentType } from '../schemas/content-type.schema';
|
||||||
import {
|
import {
|
||||||
Content,
|
Content,
|
||||||
ContentFull,
|
ContentFull,
|
||||||
@ -32,7 +33,6 @@ export class ContentService extends BaseService<
|
|||||||
> {
|
> {
|
||||||
constructor(
|
constructor(
|
||||||
readonly repository: ContentRepository,
|
readonly repository: ContentRepository,
|
||||||
private readonly attachmentService: AttachmentService,
|
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
@ -103,4 +103,68 @@ export class ContentService extends BaseService<
|
|||||||
throw err;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,81 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 { FC, Fragment, useState } from "react";
|
|
||||||
import { useQuery } from "react-query";
|
|
||||||
|
|
||||||
import AttachmentInput from "@/app-components/attachment/AttachmentInput";
|
|
||||||
import { ContentContainer, ContentItem } from "@/app-components/dialogs";
|
|
||||||
import { useApiClient } from "@/hooks/useApiClient";
|
|
||||||
import { useToast } from "@/hooks/useToast";
|
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
|
||||||
import { AttachmentResourceRef } from "@/types/attachment.types";
|
|
||||||
import { ComponentFormProps } from "@/types/common/dialogs.types";
|
|
||||||
import { IContentType } from "@/types/content-type.types";
|
|
||||||
|
|
||||||
export type ContentImportFormData = { row: null; contentType: IContentType };
|
|
||||||
export const ContentImportForm: FC<
|
|
||||||
ComponentFormProps<ContentImportFormData>
|
|
||||||
> = ({ data, Wrapper = Fragment, WrapperProps, ...rest }) => {
|
|
||||||
const [attachmentId, setAttachmentId] = useState<string | null>(null);
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { apiClient } = useApiClient();
|
|
||||||
const { refetch, isFetching } = useQuery(
|
|
||||||
["importContent", data?.contentType.id, attachmentId],
|
|
||||||
async () => {
|
|
||||||
if (data?.contentType.id && attachmentId) {
|
|
||||||
await apiClient.importContent(data.contentType.id, attachmentId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: false,
|
|
||||||
onError: () => {
|
|
||||||
rest.onError?.();
|
|
||||||
toast.error(t("message.internal_server_error"));
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
rest.onSuccess?.();
|
|
||||||
toast.success(t("message.success_save"));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const handleImportClick = () => {
|
|
||||||
if (attachmentId && data?.contentType.id) {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper
|
|
||||||
onSubmit={handleImportClick}
|
|
||||||
{...WrapperProps}
|
|
||||||
confirmButtonProps={{
|
|
||||||
...WrapperProps?.confirmButtonProps,
|
|
||||||
disabled: !attachmentId || isFetching,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form onSubmit={handleImportClick}>
|
|
||||||
<ContentContainer>
|
|
||||||
<ContentItem>
|
|
||||||
<AttachmentInput
|
|
||||||
format="basic"
|
|
||||||
accept="text/csv"
|
|
||||||
onChange={(id, _) => {
|
|
||||||
setAttachmentId(id);
|
|
||||||
}}
|
|
||||||
label=""
|
|
||||||
value={attachmentId}
|
|
||||||
resourceRef={AttachmentResourceRef.ContentAttachment}
|
|
||||||
/>
|
|
||||||
</ContentItem>
|
|
||||||
</ContentContainer>
|
|
||||||
</form>
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,26 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 { GenericFormDialog } from "@/app-components/dialogs";
|
|
||||||
import { ComponentFormDialogProps } from "@/types/common/dialogs.types";
|
|
||||||
|
|
||||||
import { ContentImportForm, ContentImportFormData } from "./ContentImportForm";
|
|
||||||
|
|
||||||
export const ContentImportFormDialog = <
|
|
||||||
T extends ContentImportFormData = ContentImportFormData,
|
|
||||||
>(
|
|
||||||
props: ComponentFormDialogProps<T>,
|
|
||||||
) => (
|
|
||||||
<GenericFormDialog<T>
|
|
||||||
Form={ContentImportForm}
|
|
||||||
rowKey="row"
|
|
||||||
addText="button.import"
|
|
||||||
confirmButtonProps={{ value: "button.import" }}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
@ -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.
|
||||||
@ -9,12 +9,13 @@
|
|||||||
import { faAlignLeft } from "@fortawesome/free-solid-svg-icons";
|
import { faAlignLeft } from "@fortawesome/free-solid-svg-icons";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import UploadIcon from "@mui/icons-material/Upload";
|
import { Button, ButtonGroup, Chip, Grid, Paper, Switch, Typography } from "@mui/material";
|
||||||
import { Button, Chip, Grid, Paper, Switch, Typography } from "@mui/material";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useQueryClient } from "react-query";
|
||||||
|
|
||||||
import { ConfirmDialogBody } from "@/app-components/dialogs";
|
import { ConfirmDialogBody } from "@/app-components/dialogs";
|
||||||
|
import FileUploadButton from "@/app-components/inputs/FileInput";
|
||||||
import { FilterTextfield } from "@/app-components/inputs/FilterTextfield";
|
import { FilterTextfield } from "@/app-components/inputs/FilterTextfield";
|
||||||
import {
|
import {
|
||||||
ActionColumnLabel,
|
ActionColumnLabel,
|
||||||
@ -22,9 +23,11 @@ import {
|
|||||||
} from "@/app-components/tables/columns/getColumns";
|
} from "@/app-components/tables/columns/getColumns";
|
||||||
import { renderHeader } from "@/app-components/tables/columns/renderHeader";
|
import { renderHeader } from "@/app-components/tables/columns/renderHeader";
|
||||||
import { DataGrid } from "@/app-components/tables/DataGrid";
|
import { DataGrid } from "@/app-components/tables/DataGrid";
|
||||||
|
import { isSameEntity } from "@/hooks/crud/helpers";
|
||||||
import { useDelete } from "@/hooks/crud/useDelete";
|
import { useDelete } from "@/hooks/crud/useDelete";
|
||||||
import { useFind } from "@/hooks/crud/useFind";
|
import { useFind } from "@/hooks/crud/useFind";
|
||||||
import { useGet, useGetFromCache } from "@/hooks/crud/useGet";
|
import { useGet, useGetFromCache } from "@/hooks/crud/useGet";
|
||||||
|
import { useImport } from "@/hooks/crud/useImport";
|
||||||
import { useUpdate } from "@/hooks/crud/useUpdate";
|
import { useUpdate } from "@/hooks/crud/useUpdate";
|
||||||
import { useDialogs } from "@/hooks/useDialogs";
|
import { useDialogs } from "@/hooks/useDialogs";
|
||||||
import { useHasPermission } from "@/hooks/useHasPermission";
|
import { useHasPermission } from "@/hooks/useHasPermission";
|
||||||
@ -38,12 +41,12 @@ import { PermissionAction } from "@/types/permission.types";
|
|||||||
import { getDateTimeFormatter } from "@/utils/date";
|
import { getDateTimeFormatter } from "@/utils/date";
|
||||||
|
|
||||||
import { ContentFormDialog } from "./ContentFormDialog";
|
import { ContentFormDialog } from "./ContentFormDialog";
|
||||||
import { ContentImportFormDialog } from "./ContentImportFormDialog";
|
|
||||||
|
|
||||||
export const Contents = () => {
|
export const Contents = () => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { query } = useRouter();
|
const { query } = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const dialogs = useDialogs();
|
const dialogs = useDialogs();
|
||||||
// data fetching
|
// data fetching
|
||||||
const { onSearch, searchPayload } = useSearch<IContent>({
|
const { onSearch, searchPayload } = useSearch<IContent>({
|
||||||
@ -51,7 +54,7 @@ export const Contents = () => {
|
|||||||
$iLike: ["title"],
|
$iLike: ["title"],
|
||||||
});
|
});
|
||||||
const hasPermission = useHasPermission();
|
const hasPermission = useHasPermission();
|
||||||
const { dataGridProps, refetch } = useFind(
|
const { dataGridProps } = useFind(
|
||||||
{ entity: EntityType.CONTENT, format: Format.FULL },
|
{ entity: EntityType.CONTENT, format: Format.FULL },
|
||||||
{
|
{
|
||||||
params: searchPayload,
|
params: searchPayload,
|
||||||
@ -97,6 +100,34 @@ export const Contents = () => {
|
|||||||
const { data: contentType } = useGet(String(query.id), {
|
const { data: contentType } = useGet(String(query.id), {
|
||||||
entity: EntityType.CONTENT_TYPE,
|
entity: EntityType.CONTENT_TYPE,
|
||||||
});
|
});
|
||||||
|
const { mutateAsync: importDataset, isLoading } = useImport(
|
||||||
|
EntityType.CONTENT,
|
||||||
|
{
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("message.import_failed"));
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.removeQueries({
|
||||||
|
predicate: ({ queryKey }) => {
|
||||||
|
const [_qType, qEntity] = queryKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
isSameEntity(qEntity, EntityType.CONTENT)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data.length) {
|
||||||
|
toast.success(t("message.success_import"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("message.import_duplicated_data"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ idTargetContentType: contentType?.id }
|
||||||
|
);
|
||||||
|
const handleImportChange = async (file: File) => {
|
||||||
|
await importDataset(file);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container flexDirection="column" gap={3}>
|
<Grid container flexDirection="column" gap={3}>
|
||||||
@ -119,6 +150,7 @@ export const Contents = () => {
|
|||||||
<FilterTextfield onChange={onSearch} />
|
<FilterTextfield onChange={onSearch} />
|
||||||
</Grid>
|
</Grid>
|
||||||
{hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? (
|
{hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? (
|
||||||
|
<ButtonGroup sx={{ marginLeft: "auto" }}>
|
||||||
<Grid item>
|
<Grid item>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
@ -127,30 +159,19 @@ export const Contents = () => {
|
|||||||
dialogs.open(ContentFormDialog, { contentType })
|
dialogs.open(ContentFormDialog, { contentType })
|
||||||
}
|
}
|
||||||
sx={{ float: "right" }}
|
sx={{ float: "right" }}
|
||||||
>
|
>
|
||||||
{t("button.add")}
|
{t("button.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
) : null}
|
<Grid item>
|
||||||
{hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? (
|
<FileUploadButton
|
||||||
<Grid item>
|
accept="text/csv"
|
||||||
<Button
|
label={t("button.import")}
|
||||||
startIcon={<UploadIcon />}
|
onChange={handleImportChange}
|
||||||
variant="contained"
|
isLoading={isLoading}
|
||||||
onClick={async () => {
|
/>
|
||||||
if (contentType) {
|
</Grid>
|
||||||
await dialogs.open(ContentImportFormDialog, {
|
</ButtonGroup>
|
||||||
row: null,
|
|
||||||
contentType,
|
|
||||||
});
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{ float: "right" }}
|
|
||||||
>
|
|
||||||
{t("button.import")}
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Grid>
|
</Grid>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
@ -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.
|
||||||
@ -25,6 +25,7 @@ export const useImport = <
|
|||||||
TMutationOptions<TBasic[], Error, TAttr, TBasic[]>,
|
TMutationOptions<TBasic[], Error, TAttr, TBasic[]>,
|
||||||
"mutationFn" | "mutationKey"
|
"mutationFn" | "mutationKey"
|
||||||
> = {},
|
> = {},
|
||||||
|
params: Record<string, any> = {},
|
||||||
) => {
|
) => {
|
||||||
const api = useEntityApiClient<TAttr, TBasic>(entity);
|
const api = useEntityApiClient<TAttr, TBasic>(entity);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -35,7 +36,7 @@ export const useImport = <
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (variables) => {
|
mutationFn: async (variables) => {
|
||||||
const data = await api.import(variables);
|
const data = await api.import(variables, params);
|
||||||
const { result, entities } = normalizeAndCache(data);
|
const { result, entities } = normalizeAndCache(data);
|
||||||
|
|
||||||
// Invalidate current entity count and collection
|
// Invalidate current entity count and collection
|
||||||
|
@ -239,14 +239,6 @@ export class ApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async importContent(contentTypeId: string, attachmentId: string) {
|
|
||||||
const { data } = await this.request.get(
|
|
||||||
`${ROUTES.CONTENT_IMPORT}/${contentTypeId}/${attachmentId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async predictNlp(text: string) {
|
async predictNlp(text: string) {
|
||||||
const { data } = await this.request.get<INlpDatasetSampleAttributes>(
|
const { data } = await this.request.get<INlpDatasetSampleAttributes>(
|
||||||
`${ROUTES.NLP_SAMPLE_PREDICT}`,
|
`${ROUTES.NLP_SAMPLE_PREDICT}`,
|
||||||
@ -288,15 +280,18 @@ export class EntityApiClient<TAttr, TBasic, TFull> extends ApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async import<T = TBasic>(file: File) {
|
async import<T = TBasic>(file: File, params?: any) {
|
||||||
const { _csrf } = await this.getCsrf();
|
const { _csrf } = await this.getCsrf();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
const { data } = await this.request.post<T[], AxiosResponse<T[]>, FormData>(
|
const { data } = await this.request.post<T[], AxiosResponse<T[]>, FormData>(
|
||||||
`${ROUTES[this.type]}/import?_csrf=${_csrf}`,
|
`${ROUTES[this.type]}/import`,
|
||||||
formData,
|
formData,
|
||||||
|
{
|
||||||
|
params: { _csrf , ...params },
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
Loading…
Reference in New Issue
Block a user