@drax/crud-back 3.9.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builders/CrudSchemaBuilder.js +3 -0
- package/dist/controllers/AbstractFastifyController.js +110 -5
- package/dist/imports/AbstractImport.js +44 -0
- package/dist/imports/ImportCsv.js +79 -0
- package/dist/imports/ImportCsvReport.js +54 -0
- package/dist/imports/ImportJson.js +14 -0
- package/dist/regexs/QueryFilterRegex.js +1 -1
- package/dist/schemas/FindSchema.js +1 -1
- package/dist/schemas/GroupBySchema.js +1 -1
- package/dist/schemas/PaginateSchema.js +1 -1
- package/dist/services/AbstractService.js +39 -0
- package/package.json +4 -4
- package/src/builders/CrudSchemaBuilder.ts +4 -0
- package/src/controllers/AbstractFastifyController.ts +125 -6
- package/src/imports/AbstractImport.ts +73 -0
- package/src/imports/ImportCsv.ts +102 -0
- package/src/imports/ImportCsvReport.ts +83 -0
- package/src/imports/ImportJson.ts +20 -0
- package/src/regexs/QueryFilterRegex.ts +1 -1
- package/src/schemas/FindSchema.ts +1 -1
- package/src/schemas/GroupBySchema.ts +1 -1
- package/src/schemas/PaginateSchema.ts +1 -1
- package/src/services/AbstractService.ts +53 -1
- package/test/controllers/PersonController.test.ts +64 -0
- package/test/services/AbstractService.test.ts +21 -10
- package/tsconfig.tsbuildinfo +1 -1
- package/types/builders/CrudSchemaBuilder.d.ts.map +1 -1
- package/types/controllers/AbstractFastifyController.d.ts +4 -1
- package/types/controllers/AbstractFastifyController.d.ts.map +1 -1
- package/types/imports/AbstractImport.d.ts +20 -0
- package/types/imports/AbstractImport.d.ts.map +1 -0
- package/types/imports/ImportCsv.d.ts +13 -0
- package/types/imports/ImportCsv.d.ts.map +1 -0
- package/types/imports/ImportCsvReport.d.ts +22 -0
- package/types/imports/ImportCsvReport.d.ts.map +1 -0
- package/types/imports/ImportJson.d.ts +7 -0
- package/types/imports/ImportJson.d.ts.map +1 -0
- package/types/regexs/QueryFilterRegex.d.ts.map +1 -1
- package/types/services/AbstractService.d.ts +3 -1
- package/types/services/AbstractService.d.ts.map +1 -1
|
@@ -377,6 +377,9 @@ export class CrudSchemaBuilder {
|
|
|
377
377
|
if (options.export !== false) {
|
|
378
378
|
fastify.get(`${basePath}/export`, (req, rep) => controller.export(req, rep));
|
|
379
379
|
}
|
|
380
|
+
if (options.create !== false) {
|
|
381
|
+
fastify.post(`${basePath}/import`, (req, rep) => controller.import(req, rep));
|
|
382
|
+
}
|
|
380
383
|
// Search entities
|
|
381
384
|
if (options.search !== false) {
|
|
382
385
|
fastify.get(`${basePath}/search`, (req, rep) => controller.search(req, rep));
|
|
@@ -2,6 +2,8 @@ import { CommonConfig, DraxConfig, LimitError, NotFoundError, BadRequestError, C
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import QueryFilterRegex from "../regexs/QueryFilterRegex.js";
|
|
4
4
|
import CrudEventEmitter from "../events/CrudEventEmitter.js";
|
|
5
|
+
import ImportCsv from "../imports/ImportCsv.js";
|
|
6
|
+
import ImportCsvReport from "../imports/ImportCsvReport.js";
|
|
5
7
|
class AbstractFastifyController extends CommonController {
|
|
6
8
|
constructor(service, permission, entityName) {
|
|
7
9
|
super();
|
|
@@ -42,9 +44,9 @@ class AbstractFastifyController extends CommonController {
|
|
|
42
44
|
const filterArray = stringFilters.split("|");
|
|
43
45
|
const filters = [];
|
|
44
46
|
filterArray.forEach((filter) => {
|
|
45
|
-
const [field, operator, value] = filter.split(";");
|
|
47
|
+
const [field, operator, value, orGroup] = filter.split(";");
|
|
46
48
|
if (field && operator && (operator === 'empty' || (value !== undefined && value !== ''))) {
|
|
47
|
-
filters.push({ field, operator, value });
|
|
49
|
+
filters.push({ field, operator, value, orGroup: orGroup || undefined });
|
|
48
50
|
}
|
|
49
51
|
});
|
|
50
52
|
return filters;
|
|
@@ -192,12 +194,20 @@ class AbstractFastifyController extends CommonController {
|
|
|
192
194
|
async postCreate(request, item) {
|
|
193
195
|
return item;
|
|
194
196
|
}
|
|
197
|
+
async prepareCreatePayload(request, payload) {
|
|
198
|
+
this.applyUserAndTenantSetters(payload, request.rbac);
|
|
199
|
+
return await this.preCreate(request, payload);
|
|
200
|
+
}
|
|
201
|
+
getErrorMessage(error) {
|
|
202
|
+
if (error?.message) {
|
|
203
|
+
return error.message;
|
|
204
|
+
}
|
|
205
|
+
return 'Unknown import error';
|
|
206
|
+
}
|
|
195
207
|
async create(request, reply) {
|
|
196
208
|
try {
|
|
197
209
|
request.rbac.assertPermission(this.permission.Create);
|
|
198
|
-
const payload = request.body;
|
|
199
|
-
this.applyUserAndTenantSetters(payload, request.rbac);
|
|
200
|
-
await this.preCreate(request, payload);
|
|
210
|
+
const payload = await this.prepareCreatePayload(request, request.body);
|
|
201
211
|
let item = await this.service.create(payload);
|
|
202
212
|
this.onCreated(request, item);
|
|
203
213
|
item = await this.postCreate(request, item);
|
|
@@ -569,6 +579,101 @@ class AbstractFastifyController extends CommonController {
|
|
|
569
579
|
this.handleError(e, reply);
|
|
570
580
|
}
|
|
571
581
|
}
|
|
582
|
+
async import(request, reply) {
|
|
583
|
+
try {
|
|
584
|
+
request.rbac.assertPermission(this.permission.Create);
|
|
585
|
+
const data = await request.file();
|
|
586
|
+
if (!data) {
|
|
587
|
+
throw new BadRequestError('Import file is required');
|
|
588
|
+
}
|
|
589
|
+
const format = (request.query.format || data.filename.split('.').pop() || 'json').toUpperCase();
|
|
590
|
+
if (!['CSV', 'JSON'].includes(format)) {
|
|
591
|
+
throw new BadRequestError(`Unsupported import format: ${format}`);
|
|
592
|
+
}
|
|
593
|
+
const separator = request.query.separator || ';';
|
|
594
|
+
data.file.setEncoding('utf8');
|
|
595
|
+
let rawContent = '';
|
|
596
|
+
for await (const chunk of data.file) {
|
|
597
|
+
rawContent += chunk;
|
|
598
|
+
}
|
|
599
|
+
const start = Date.now();
|
|
600
|
+
let rowCount = 0;
|
|
601
|
+
let successCount = 0;
|
|
602
|
+
let errorCount = 0;
|
|
603
|
+
let reportFileName = undefined;
|
|
604
|
+
let reportUrl = undefined;
|
|
605
|
+
if (format === 'CSV') {
|
|
606
|
+
const importer = new ImportCsv({ content: rawContent, separator });
|
|
607
|
+
const parsedImport = importer.processDetailed();
|
|
608
|
+
rowCount = parsedImport.rows.length;
|
|
609
|
+
const reportRows = [];
|
|
610
|
+
for (const row of parsedImport.rows) {
|
|
611
|
+
try {
|
|
612
|
+
const payload = await this.prepareCreatePayload(request, row.item);
|
|
613
|
+
let item = await this.service.create(payload);
|
|
614
|
+
this.onCreated(request, item);
|
|
615
|
+
await this.postCreate(request, item);
|
|
616
|
+
successCount++;
|
|
617
|
+
reportRows.push({ rawValues: row.rawValues, status: 'success', error: '' });
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
errorCount++;
|
|
621
|
+
reportRows.push({
|
|
622
|
+
rawValues: row.rawValues,
|
|
623
|
+
status: 'error',
|
|
624
|
+
error: this.getErrorMessage(e)
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const year = (new Date().getFullYear()).toString();
|
|
629
|
+
const month = (new Date().getMonth() + 1).toString().padStart(2, '0');
|
|
630
|
+
const importPath = 'imports';
|
|
631
|
+
const destinationPath = join(this.baseFileDir, importPath, year, month);
|
|
632
|
+
const report = await new ImportCsvReport({
|
|
633
|
+
destinationPath,
|
|
634
|
+
fileName: `${this.entityName.toLowerCase()}_import_report`,
|
|
635
|
+
headers: parsedImport.headers,
|
|
636
|
+
separator,
|
|
637
|
+
rows: reportRows,
|
|
638
|
+
}).process();
|
|
639
|
+
reportFileName = report.fileName;
|
|
640
|
+
reportUrl = `${this.baseURL}/api/file/${importPath}/${year}/${month}/${report.fileName}`;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const items = this.service.parseImport({
|
|
644
|
+
format,
|
|
645
|
+
content: rawContent,
|
|
646
|
+
separator,
|
|
647
|
+
});
|
|
648
|
+
rowCount = items.length;
|
|
649
|
+
for (const rawItem of items) {
|
|
650
|
+
try {
|
|
651
|
+
const payload = await this.prepareCreatePayload(request, rawItem);
|
|
652
|
+
let item = await this.service.create(payload);
|
|
653
|
+
this.onCreated(request, item);
|
|
654
|
+
await this.postCreate(request, item);
|
|
655
|
+
successCount++;
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
errorCount++;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const response = {
|
|
663
|
+
rowCount,
|
|
664
|
+
successCount,
|
|
665
|
+
errorCount,
|
|
666
|
+
time: Date.now() - start,
|
|
667
|
+
message: errorCount > 0 ? 'Import completed with errors' : 'Import successful',
|
|
668
|
+
fileName: reportFileName,
|
|
669
|
+
url: reportUrl,
|
|
670
|
+
};
|
|
671
|
+
return response;
|
|
672
|
+
}
|
|
673
|
+
catch (e) {
|
|
674
|
+
this.handleError(e, reply);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
572
677
|
async groupBy(request, reply) {
|
|
573
678
|
try {
|
|
574
679
|
request.rbac.assertPermission(this.permission.View);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { setNestedValue } from "@drax/common-back";
|
|
2
|
+
class AbstractImport {
|
|
3
|
+
constructor(options) {
|
|
4
|
+
this.content = options.content;
|
|
5
|
+
this.separator = options.separator || ';';
|
|
6
|
+
}
|
|
7
|
+
parseValue(value) {
|
|
8
|
+
const trimmedValue = value.trim();
|
|
9
|
+
if (trimmedValue === '') {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
if (trimmedValue === 'null') {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (trimmedValue === 'true') {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (trimmedValue === 'false') {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmedValue)) {
|
|
22
|
+
return Number(trimmedValue);
|
|
23
|
+
}
|
|
24
|
+
if ((trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) ||
|
|
25
|
+
(trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(trimmedValue);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
assignNestedValue(record, key, value) {
|
|
36
|
+
if (key.includes('.')) {
|
|
37
|
+
setNestedValue(record, key, value);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
record[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export { AbstractImport };
|
|
44
|
+
export default AbstractImport;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import AbstractImport from "./AbstractImport.js";
|
|
2
|
+
class ImportCsv extends AbstractImport {
|
|
3
|
+
constructor(options) {
|
|
4
|
+
super(options);
|
|
5
|
+
}
|
|
6
|
+
process() {
|
|
7
|
+
return this.processDetailed().rows.map(row => row.item);
|
|
8
|
+
}
|
|
9
|
+
processDetailed() {
|
|
10
|
+
const rows = this.parseRows(this.content);
|
|
11
|
+
if (rows.length === 0) {
|
|
12
|
+
return { headers: [], rows: [] };
|
|
13
|
+
}
|
|
14
|
+
const [headers, ...dataRows] = rows;
|
|
15
|
+
return {
|
|
16
|
+
headers,
|
|
17
|
+
rows: dataRows
|
|
18
|
+
.filter(row => row.some(value => value.trim() !== ''))
|
|
19
|
+
.map((row) => {
|
|
20
|
+
const item = {};
|
|
21
|
+
headers.forEach((header, index) => {
|
|
22
|
+
const normalizedHeader = header.trim();
|
|
23
|
+
if (!normalizedHeader) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.assignNestedValue(item, normalizedHeader, this.parseValue(row[index] ?? ''));
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
rawValues: row,
|
|
30
|
+
item
|
|
31
|
+
};
|
|
32
|
+
})
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
parseRows(content) {
|
|
36
|
+
const rows = [];
|
|
37
|
+
let currentRow = [];
|
|
38
|
+
let currentValue = '';
|
|
39
|
+
let inQuotes = false;
|
|
40
|
+
for (let index = 0; index < content.length; index++) {
|
|
41
|
+
const char = content[index];
|
|
42
|
+
const nextChar = content[index + 1];
|
|
43
|
+
if (char === '"') {
|
|
44
|
+
if (inQuotes && nextChar === '"') {
|
|
45
|
+
currentValue += '"';
|
|
46
|
+
index++;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
inQuotes = !inQuotes;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (char === this.separator && !inQuotes) {
|
|
54
|
+
currentRow.push(currentValue);
|
|
55
|
+
currentValue = '';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if ((char === '\n' || char === '\r') && !inQuotes) {
|
|
59
|
+
if (char === '\r' && nextChar === '\n') {
|
|
60
|
+
index++;
|
|
61
|
+
}
|
|
62
|
+
currentRow.push(currentValue);
|
|
63
|
+
if (currentRow.some(value => value !== '')) {
|
|
64
|
+
rows.push(currentRow);
|
|
65
|
+
}
|
|
66
|
+
currentRow = [];
|
|
67
|
+
currentValue = '';
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
currentValue += char;
|
|
71
|
+
}
|
|
72
|
+
currentRow.push(currentValue);
|
|
73
|
+
if (currentRow.some(value => value !== '')) {
|
|
74
|
+
rows.push(currentRow);
|
|
75
|
+
}
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export default ImportCsv;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import AbstractExport from "../exports/AbstractExport.js";
|
|
3
|
+
class ImportCsvReport extends AbstractExport {
|
|
4
|
+
constructor(options) {
|
|
5
|
+
super({
|
|
6
|
+
cursor: [],
|
|
7
|
+
destinationPath: options.destinationPath,
|
|
8
|
+
headers: options.headers,
|
|
9
|
+
fileName: options.fileName || 'import_report',
|
|
10
|
+
});
|
|
11
|
+
this.separator = options.separator || ';';
|
|
12
|
+
this.rows = options.rows;
|
|
13
|
+
}
|
|
14
|
+
process() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
try {
|
|
17
|
+
this.generateFilePath('csv');
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
const writableStream = fs.createWriteStream(this.relativeFilePath);
|
|
20
|
+
writableStream.on('error', reject);
|
|
21
|
+
writableStream.on('finish', () => resolve({
|
|
22
|
+
status: 'success',
|
|
23
|
+
destinationPath: this.destinationPath,
|
|
24
|
+
fileName: this.fileName,
|
|
25
|
+
filePath: this.destinationPath + '/' + this.fileName,
|
|
26
|
+
relativeFilePath: this.relativeFilePath,
|
|
27
|
+
rowCount: this.rows.length,
|
|
28
|
+
time: Date.now() - start,
|
|
29
|
+
message: 'Import report generated',
|
|
30
|
+
}));
|
|
31
|
+
writableStream.write([...this.headers, 'import_status', 'error'].join(this.separator) + '\n');
|
|
32
|
+
for (const row of this.rows) {
|
|
33
|
+
const values = [...row.rawValues, row.status, row.error || ''].map(value => this.escapeCsvValue(value));
|
|
34
|
+
writableStream.write(values.join(this.separator) + '\n');
|
|
35
|
+
}
|
|
36
|
+
writableStream.end();
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
reject(e);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
escapeCsvValue(value) {
|
|
44
|
+
let formattedValue = value === null || value === undefined ? '' : String(value);
|
|
45
|
+
if (formattedValue.includes(this.separator) ||
|
|
46
|
+
formattedValue.includes('"') ||
|
|
47
|
+
formattedValue.includes('\n') ||
|
|
48
|
+
formattedValue.includes('\r')) {
|
|
49
|
+
formattedValue = '"' + formattedValue.replace(/"/g, '""') + '"';
|
|
50
|
+
}
|
|
51
|
+
return formattedValue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export default ImportCsvReport;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import AbstractImport from "./AbstractImport.js";
|
|
2
|
+
class ImportJson extends AbstractImport {
|
|
3
|
+
constructor(options) {
|
|
4
|
+
super(options);
|
|
5
|
+
}
|
|
6
|
+
process() {
|
|
7
|
+
const parsedContent = JSON.parse(this.content);
|
|
8
|
+
if (!Array.isArray(parsedContent)) {
|
|
9
|
+
throw new Error('Invalid JSON import format. Expected an array of objects');
|
|
10
|
+
}
|
|
11
|
+
return parsedContent;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export default ImportJson;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
const QueryFilterRegex = /^(?:[a-zA-Z0-9_.\-]+;(?:eq|like|ne|in|nin|gt|gte|lt|lte|empty);[a-zA-Z0-9_.\-:\., áéíóúÁÉÍÓÚ]*)(?:\|[a-zA-Z0-9_.\-]+;(?:eq|like|ne|in|nin|gt|gte|lt|lte|empty);[a-zA-Z0-9_.\-:\., áéíóúÁÉÍÓÚ]*)*$/;
|
|
1
|
+
const QueryFilterRegex = /^(?:[a-zA-Z0-9_.\-]+;(?:eq|like|ne|in|nin|gt|gte|lt|lte|empty);[a-zA-Z0-9_.\-:\., áéíóúÁÉÍÓÚ]*(?:;[a-zA-Z0-9_.\-]+)?)(?:\|[a-zA-Z0-9_.\-]+;(?:eq|like|ne|in|nin|gt|gte|lt|lte|empty);[a-zA-Z0-9_.\-:\., áéíóúÁÉÍÓÚ]*(?:;[a-zA-Z0-9_.\-]+)?)*$/;
|
|
2
2
|
export default QueryFilterRegex;
|
|
3
3
|
export { QueryFilterRegex };
|
|
@@ -4,6 +4,6 @@ const FindQuerySchema = z.object({
|
|
|
4
4
|
orderBy: z.string().optional(),
|
|
5
5
|
order: z.enum(["asc", "desc"]).optional(),
|
|
6
6
|
search: z.string().optional(),
|
|
7
|
-
filters: z.string().regex(QueryFilterRegex).optional().describe("Format: field;operator;value|field;operator;value|..."),
|
|
7
|
+
filters: z.string().regex(QueryFilterRegex).optional().describe("Format: field;operator;value[;orGroup]|field;operator;value[;orGroup]|..."),
|
|
8
8
|
});
|
|
9
9
|
export { FindQuerySchema };
|
|
@@ -2,6 +2,6 @@ import z from "zod";
|
|
|
2
2
|
import QueryFilterRegex from "../regexs/QueryFilterRegex.js";
|
|
3
3
|
const GroupByQuerySchema = z.object({
|
|
4
4
|
fields: z.array(z.string()).min(1).max(10),
|
|
5
|
-
filters: z.string().regex(QueryFilterRegex).optional().describe("Format: field;operator;value|field;operator;value|..."),
|
|
5
|
+
filters: z.string().regex(QueryFilterRegex).optional().describe("Format: field;operator;value[;orGroup]|field;operator;value[;orGroup]|..."),
|
|
6
6
|
});
|
|
7
7
|
export { GroupByQuerySchema };
|
|
@@ -6,7 +6,7 @@ const PaginateQuerySchema = z.object({
|
|
|
6
6
|
orderBy: z.string().optional(),
|
|
7
7
|
order: z.enum(["asc", "desc"]).optional(),
|
|
8
8
|
search: z.string().optional(),
|
|
9
|
-
filters: z.string().regex(QueryFilterRegex).optional().describe("Format: field;operator;value|field;operator;value|..."),
|
|
9
|
+
filters: z.string().regex(QueryFilterRegex).optional().describe("Format: field;operator;value[;orGroup]|field;operator;value[;orGroup]|..."),
|
|
10
10
|
});
|
|
11
11
|
const PaginateBodyResponseSchema = z.object({
|
|
12
12
|
page: z.number(),
|
|
@@ -2,6 +2,8 @@ import { ZodErrorToValidationError } from "@drax/common-back";
|
|
|
2
2
|
import { ZodError } from "zod";
|
|
3
3
|
import ExportCsv from "../exports/ExportCsv.js";
|
|
4
4
|
import ExportJson from "../exports/ExportJson.js";
|
|
5
|
+
import ImportCsv from "../imports/ImportCsv.js";
|
|
6
|
+
import ImportJson from "../imports/ImportJson.js";
|
|
5
7
|
class AbstractService {
|
|
6
8
|
constructor(repository, baseSchema, fullSchema) {
|
|
7
9
|
this._validateOutput = true;
|
|
@@ -354,6 +356,43 @@ class AbstractService {
|
|
|
354
356
|
throw e;
|
|
355
357
|
}
|
|
356
358
|
}
|
|
359
|
+
parseImport({ format = 'JSON', content, separator = ';' }) {
|
|
360
|
+
let importer;
|
|
361
|
+
switch (format) {
|
|
362
|
+
case 'JSON':
|
|
363
|
+
importer = new ImportJson({ content, separator });
|
|
364
|
+
break;
|
|
365
|
+
case 'CSV':
|
|
366
|
+
importer = new ImportCsv({ content, separator });
|
|
367
|
+
break;
|
|
368
|
+
default:
|
|
369
|
+
throw new Error(`Unsupported import format: ${format}`);
|
|
370
|
+
}
|
|
371
|
+
return importer.process();
|
|
372
|
+
}
|
|
373
|
+
async import({ format = 'JSON', content, separator = ';' }) {
|
|
374
|
+
try {
|
|
375
|
+
const start = Date.now();
|
|
376
|
+
const items = this.parseImport({ format, content, separator });
|
|
377
|
+
for (const item of items) {
|
|
378
|
+
await this.create(item);
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
status: 'success',
|
|
382
|
+
rowCount: items.length,
|
|
383
|
+
time: Date.now() - start,
|
|
384
|
+
message: 'Import successful',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
console.error("Error import", {
|
|
389
|
+
name: e?.name,
|
|
390
|
+
message: e?.message,
|
|
391
|
+
stack: e?.stack,
|
|
392
|
+
});
|
|
393
|
+
throw e;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
357
396
|
}
|
|
358
397
|
export default AbstractService;
|
|
359
398
|
export { AbstractService };
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.11.0",
|
|
7
7
|
"description": "Crud utils across modules",
|
|
8
8
|
"main": "dist/index.js",
|
|
9
9
|
"types": "types/index.d.ts",
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"author": "Cristian Incarnato & Drax Team",
|
|
23
23
|
"license": "ISC",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@drax/common-back": "^3.
|
|
25
|
+
"@drax/common-back": "^3.10.0",
|
|
26
26
|
"@drax/common-share": "^3.0.0",
|
|
27
27
|
"@drax/identity-share": "^3.0.0",
|
|
28
|
-
"@drax/media-back": "^3.
|
|
28
|
+
"@drax/media-back": "^3.11.0",
|
|
29
29
|
"@graphql-tools/load-files": "^7.0.0",
|
|
30
30
|
"@graphql-tools/merge": "^9.0.4",
|
|
31
31
|
"mongoose": "^8.23.0",
|
|
@@ -47,5 +47,5 @@
|
|
|
47
47
|
"typescript": "^5.9.3",
|
|
48
48
|
"vitest": "^3.2.4"
|
|
49
49
|
},
|
|
50
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "9f9de6fe51c4f11d885385bd31a12edec799823e"
|
|
51
51
|
}
|
|
@@ -472,6 +472,10 @@ export class CrudSchemaBuilder<
|
|
|
472
472
|
fastify.get(`${basePath}/export`, (req, rep) => controller.export(req, rep));
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
if (options.create !== false) {
|
|
476
|
+
fastify.post(`${basePath}/import`, (req, rep) => controller.import(req, rep));
|
|
477
|
+
}
|
|
478
|
+
|
|
475
479
|
// Search entities
|
|
476
480
|
if (options.search !== false) {
|
|
477
481
|
fastify.get(`${basePath}/search`, (req, rep) => controller.search(req, rep));
|
|
@@ -15,11 +15,14 @@ import {
|
|
|
15
15
|
IDraxFieldFilter,
|
|
16
16
|
IDraxExportResponse,
|
|
17
17
|
IDraxCrudEvent,
|
|
18
|
-
IDraxPaginateResult
|
|
18
|
+
IDraxPaginateResult,
|
|
19
|
+
IDraxImportResponse
|
|
19
20
|
} from "@drax/crud-share";
|
|
20
21
|
import {join} from "path";
|
|
21
22
|
import QueryFilterRegex from "../regexs/QueryFilterRegex.js";
|
|
22
23
|
import CrudEventEmitter from "../events/CrudEventEmitter.js";
|
|
24
|
+
import ImportCsv from "../imports/ImportCsv.js";
|
|
25
|
+
import ImportCsvReport from "../imports/ImportCsvReport.js";
|
|
23
26
|
|
|
24
27
|
declare module 'fastify' {
|
|
25
28
|
interface FastifyRequest {
|
|
@@ -112,10 +115,10 @@ class AbstractFastifyController<T, C, U> extends CommonController {
|
|
|
112
115
|
const filterArray = stringFilters.split("|")
|
|
113
116
|
const filters: IDraxFieldFilter[] = []
|
|
114
117
|
filterArray.forEach((filter) => {
|
|
115
|
-
const [field, operator, value] = filter.split(";")
|
|
118
|
+
const [field, operator, value, orGroup] = filter.split(";")
|
|
116
119
|
|
|
117
120
|
if (field && operator && (operator === 'empty' || (value !== undefined && value !== ''))) {
|
|
118
|
-
filters.push({field, operator, value})
|
|
121
|
+
filters.push({field, operator, value, orGroup: orGroup || undefined})
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
})
|
|
@@ -285,12 +288,22 @@ class AbstractFastifyController<T, C, U> extends CommonController {
|
|
|
285
288
|
return item
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
protected async prepareCreatePayload(request: CustomRequest, payload: any): Promise<C> {
|
|
292
|
+
this.applyUserAndTenantSetters(payload, request.rbac)
|
|
293
|
+
return await this.preCreate(request, payload as C)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
protected getErrorMessage(error: any): string {
|
|
297
|
+
if (error?.message) {
|
|
298
|
+
return error.message
|
|
299
|
+
}
|
|
300
|
+
return 'Unknown import error'
|
|
301
|
+
}
|
|
302
|
+
|
|
288
303
|
async create(request: CustomRequest, reply: FastifyReply) {
|
|
289
304
|
try {
|
|
290
305
|
request.rbac.assertPermission(this.permission.Create)
|
|
291
|
-
const payload = request.body
|
|
292
|
-
this.applyUserAndTenantSetters(payload, request.rbac)
|
|
293
|
-
await this.preCreate(request, payload as C)
|
|
306
|
+
const payload = await this.prepareCreatePayload(request, request.body)
|
|
294
307
|
let item = await this.service.create(payload as C)
|
|
295
308
|
this.onCreated(request, item)
|
|
296
309
|
item = await this.postCreate(request, item as T)
|
|
@@ -758,6 +771,112 @@ class AbstractFastifyController<T, C, U> extends CommonController {
|
|
|
758
771
|
}
|
|
759
772
|
}
|
|
760
773
|
|
|
774
|
+
async import(request: CustomRequest, reply: FastifyReply) {
|
|
775
|
+
try {
|
|
776
|
+
request.rbac.assertPermission(this.permission.Create)
|
|
777
|
+
|
|
778
|
+
const data = await (request as any).file()
|
|
779
|
+
if (!data) {
|
|
780
|
+
throw new BadRequestError('Import file is required')
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const format = ((request.query.format || data.filename.split('.').pop() || 'json') as string).toUpperCase() as 'CSV' | 'JSON'
|
|
784
|
+
if (!['CSV', 'JSON'].includes(format)) {
|
|
785
|
+
throw new BadRequestError(`Unsupported import format: ${format}`)
|
|
786
|
+
}
|
|
787
|
+
const separator = request.query.separator || ';'
|
|
788
|
+
data.file.setEncoding('utf8')
|
|
789
|
+
|
|
790
|
+
let rawContent = ''
|
|
791
|
+
for await (const chunk of data.file) {
|
|
792
|
+
rawContent += chunk
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const start = Date.now()
|
|
796
|
+
let rowCount = 0
|
|
797
|
+
let successCount = 0
|
|
798
|
+
let errorCount = 0
|
|
799
|
+
let reportFileName: string | undefined = undefined
|
|
800
|
+
let reportUrl: string | undefined = undefined
|
|
801
|
+
|
|
802
|
+
if (format === 'CSV') {
|
|
803
|
+
const importer = new ImportCsv({content: rawContent, separator})
|
|
804
|
+
const parsedImport = importer.processDetailed()
|
|
805
|
+
rowCount = parsedImport.rows.length
|
|
806
|
+
|
|
807
|
+
const reportRows: Array<{rawValues: string[], status: 'success' | 'error', error?: string}> = []
|
|
808
|
+
|
|
809
|
+
for (const row of parsedImport.rows) {
|
|
810
|
+
try {
|
|
811
|
+
const payload = await this.prepareCreatePayload(request, row.item)
|
|
812
|
+
let item = await this.service.create(payload as C)
|
|
813
|
+
this.onCreated(request, item)
|
|
814
|
+
await this.postCreate(request, item as T)
|
|
815
|
+
successCount++
|
|
816
|
+
reportRows.push({rawValues: row.rawValues, status: 'success', error: ''})
|
|
817
|
+
} catch (e) {
|
|
818
|
+
errorCount++
|
|
819
|
+
reportRows.push({
|
|
820
|
+
rawValues: row.rawValues,
|
|
821
|
+
status: 'error',
|
|
822
|
+
error: this.getErrorMessage(e)
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const year = (new Date().getFullYear()).toString()
|
|
828
|
+
const month = (new Date().getMonth() + 1).toString().padStart(2, '0')
|
|
829
|
+
const importPath = 'imports'
|
|
830
|
+
const destinationPath = join(this.baseFileDir, importPath, year, month)
|
|
831
|
+
|
|
832
|
+
const report = await new ImportCsvReport({
|
|
833
|
+
destinationPath,
|
|
834
|
+
fileName: `${this.entityName.toLowerCase()}_import_report`,
|
|
835
|
+
headers: parsedImport.headers,
|
|
836
|
+
separator,
|
|
837
|
+
rows: reportRows,
|
|
838
|
+
}).process()
|
|
839
|
+
|
|
840
|
+
reportFileName = report.fileName
|
|
841
|
+
reportUrl = `${this.baseURL}/api/file/${importPath}/${year}/${month}/${report.fileName}`
|
|
842
|
+
} else {
|
|
843
|
+
const items = this.service.parseImport({
|
|
844
|
+
format,
|
|
845
|
+
content: rawContent,
|
|
846
|
+
separator,
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
rowCount = items.length
|
|
850
|
+
|
|
851
|
+
for (const rawItem of items) {
|
|
852
|
+
try {
|
|
853
|
+
const payload = await this.prepareCreatePayload(request, rawItem)
|
|
854
|
+
let item = await this.service.create(payload as C)
|
|
855
|
+
this.onCreated(request, item)
|
|
856
|
+
await this.postCreate(request, item as T)
|
|
857
|
+
successCount++
|
|
858
|
+
} catch (e) {
|
|
859
|
+
errorCount++
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const response: IDraxImportResponse = {
|
|
865
|
+
rowCount,
|
|
866
|
+
successCount,
|
|
867
|
+
errorCount,
|
|
868
|
+
time: Date.now() - start,
|
|
869
|
+
message: errorCount > 0 ? 'Import completed with errors' : 'Import successful',
|
|
870
|
+
fileName: reportFileName,
|
|
871
|
+
url: reportUrl,
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return response
|
|
875
|
+
} catch (e) {
|
|
876
|
+
this.handleError(e, reply)
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
761
880
|
async groupBy(request: CustomRequest, reply: FastifyReply) {
|
|
762
881
|
try {
|
|
763
882
|
request.rbac.assertPermission(this.permission.View)
|