@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.
Files changed (40) hide show
  1. package/dist/builders/CrudSchemaBuilder.js +3 -0
  2. package/dist/controllers/AbstractFastifyController.js +110 -5
  3. package/dist/imports/AbstractImport.js +44 -0
  4. package/dist/imports/ImportCsv.js +79 -0
  5. package/dist/imports/ImportCsvReport.js +54 -0
  6. package/dist/imports/ImportJson.js +14 -0
  7. package/dist/regexs/QueryFilterRegex.js +1 -1
  8. package/dist/schemas/FindSchema.js +1 -1
  9. package/dist/schemas/GroupBySchema.js +1 -1
  10. package/dist/schemas/PaginateSchema.js +1 -1
  11. package/dist/services/AbstractService.js +39 -0
  12. package/package.json +4 -4
  13. package/src/builders/CrudSchemaBuilder.ts +4 -0
  14. package/src/controllers/AbstractFastifyController.ts +125 -6
  15. package/src/imports/AbstractImport.ts +73 -0
  16. package/src/imports/ImportCsv.ts +102 -0
  17. package/src/imports/ImportCsvReport.ts +83 -0
  18. package/src/imports/ImportJson.ts +20 -0
  19. package/src/regexs/QueryFilterRegex.ts +1 -1
  20. package/src/schemas/FindSchema.ts +1 -1
  21. package/src/schemas/GroupBySchema.ts +1 -1
  22. package/src/schemas/PaginateSchema.ts +1 -1
  23. package/src/services/AbstractService.ts +53 -1
  24. package/test/controllers/PersonController.test.ts +64 -0
  25. package/test/services/AbstractService.test.ts +21 -10
  26. package/tsconfig.tsbuildinfo +1 -1
  27. package/types/builders/CrudSchemaBuilder.d.ts.map +1 -1
  28. package/types/controllers/AbstractFastifyController.d.ts +4 -1
  29. package/types/controllers/AbstractFastifyController.d.ts.map +1 -1
  30. package/types/imports/AbstractImport.d.ts +20 -0
  31. package/types/imports/AbstractImport.d.ts.map +1 -0
  32. package/types/imports/ImportCsv.d.ts +13 -0
  33. package/types/imports/ImportCsv.d.ts.map +1 -0
  34. package/types/imports/ImportCsvReport.d.ts +22 -0
  35. package/types/imports/ImportCsvReport.d.ts.map +1 -0
  36. package/types/imports/ImportJson.d.ts +7 -0
  37. package/types/imports/ImportJson.d.ts.map +1 -0
  38. package/types/regexs/QueryFilterRegex.d.ts.map +1 -1
  39. package/types/services/AbstractService.d.ts +3 -1
  40. 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.9.0",
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.0.0",
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.9.0",
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": "b1af2464a5d229ac728d216fe18e680322cfe158"
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)