@solidstarters/solid-core 1.2.115 → 1.2.116

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidstarters/solid-core",
3
- "version": "1.2.115",
3
+ "version": "1.2.116",
4
4
  "description": "This module is a NestJS module containing all the required core providers required by a Solid application",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -107,6 +107,21 @@ export class ImportTransactionController {
107
107
  return this.service.getImportMappingInfo(+id);
108
108
  }
109
109
 
110
+ @ApiBearerAuth("jwt")
111
+ @Get(':id/export-failed-import-records')
112
+ async exportFailedImportedImports(@Param('id') id: string, @Res() res: Response) {
113
+ const {stream, fileName, mimeType} = await this.service.exportFailedImportedImports(+id);
114
+ if (stream === null) {
115
+ throw new InternalServerErrorException("Failed records stream is null");
116
+ }
117
+ // ✅ Set response headers for streaming
118
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
119
+ res.setHeader('Content-Type', mimeType);
120
+ res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition, Content-Type');
121
+ // Pipe the strea to the response as an excel file
122
+ stream.pipe(res);
123
+ }
124
+
110
125
  @ApiBearerAuth("jwt")
111
126
  @Post(':id/start-import/sync')
112
127
  async startImportSync(@Param('id') id: string) {
@@ -167,7 +167,7 @@ export class ExportTransactionService extends CRUDService<ExportTransaction> {
167
167
  exportStream = await this.excelService.createExcelStream(dataRecordsFunc, EXPORT_CHUNK_SIZE);
168
168
  break;
169
169
  case ExportFormat.CSV:
170
- exportStream = await await this.csvService.createCsvStream(dataRecordsFunc, EXPORT_CHUNK_SIZE);
170
+ exportStream = await this.csvService.createCsvStream(dataRecordsFunc, EXPORT_CHUNK_SIZE);
171
171
  break;
172
172
  default:
173
173
  throw new Error('Invalid export format');
@@ -1,4 +1,4 @@
1
- import { Injectable, Logger } from '@nestjs/common';
1
+ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
2
2
  import { DiscoveryService, ModuleRef } from "@nestjs/core";
3
3
  import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
4
4
  import { EntityManager, Repository } from 'typeorm';
@@ -16,9 +16,11 @@ import { HttpService } from '@nestjs/axios';
16
16
  import { RelationFieldsCommand, RelationType, SolidFieldType } from 'src/dtos/create-field-metadata.dto';
17
17
  import { ImportInstructionsResponseDto, StandardImportInstructionsResponseDto } from 'src/dtos/import-instructions.dto';
18
18
  import { FieldMetadata } from 'src/entities/field-metadata.entity';
19
+ import { ImportTransactionErrorLog } from 'src/entities/import-transaction-error-log.entity';
19
20
  import { ModelMetadata } from 'src/entities/model-metadata.entity';
20
21
  import { MediaWithFullUrl } from 'src/interfaces';
21
22
  import { Readable } from 'stream';
23
+ import { v4 as uuidv4 } from 'uuid';
22
24
  import { ImportTransaction } from '../entities/import-transaction.entity';
23
25
  import { CsvService } from './csv.service';
24
26
  import { ExcelService } from './excel.service';
@@ -34,6 +36,11 @@ export enum ImportFormat {
34
36
  CSV = 'csv',
35
37
  EXCEL = 'excel',
36
38
  }
39
+
40
+ export enum ImportMimeTypes {
41
+ CSV = 'text/csv',
42
+ EXCEL = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
43
+ }
37
44
  export interface ImportMappingInfo {
38
45
  sampleImportedRecordInfo: SampleImportedRecordInfo[];
39
46
  importableFields: ImportableFieldInfo[];
@@ -63,6 +70,11 @@ export interface ImportSyncResult {
63
70
  importedIds: Array<number>; // The IDs of the records created during the import
64
71
  }
65
72
 
73
+ interface ImportRecordsResult {
74
+ ids: Array<number>; // The IDs of the records created during the import
75
+ errorLogIds: Array<number>; // The IDs of the error log entries created during the import
76
+ }
77
+
66
78
  @Injectable()
67
79
  export class ImportTransactionService extends CRUDService<ImportTransaction> {
68
80
  constructor(
@@ -242,25 +254,106 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
242
254
  // Get the import file stream for the import transaction
243
255
  const importFileStream = await this.getImportFileStream(importFileMediaObject);
244
256
 
245
- const ids = await this.writeFileRecordsToDb(
257
+ const { ids, errorLogIds } = await this.importFromFileToDB(
258
+ importTransaction,
246
259
  importFileStream,
247
260
  importFileMediaObject.mimeType,
248
- JSON.parse(importTransaction.mapping) as ImportMapping[], // Parse the mapping from the import transaction
249
- importTransaction.modelMetadata,
250
261
  );
251
262
 
252
263
  // Update the import transaction status to 'completed'
253
- importTransaction.status = 'import_succeeded';
264
+ (errorLogIds.length > 0) ? importTransaction.status = 'import_failed' : importTransaction.status = 'import_succeeded'; //FIXME: We can probably have import_partially_failed status to differentiate
254
265
  // Save the import transaction
255
266
  await this.repo.save(importTransaction);
256
267
 
257
- return {status: importTransaction.status, importedIds: ids}; // Return the IDs of the created records
268
+ return { status: importTransaction.status, importedIds: ids }; // Return the IDs of the created records
258
269
  }
259
270
 
260
271
  startImportAsync(importTransactionId: number): Promise<void> {
261
272
  throw new Error('Method not implemented.');
262
273
  }
263
274
 
275
+ async exportFailedImportedImports(importTransactionId: number) {
276
+ // Get the 1st error log entry to determine the headers for the export file
277
+ const firstErrorLogEntry = await this.entityManager.getRepository(ImportTransactionErrorLog).findOne({
278
+ where: {
279
+ importTransaction: { id: importTransactionId },
280
+ },
281
+ });
282
+
283
+ if (!firstErrorLogEntry) {
284
+ throw new BadRequestException(`No error log entries found for import transaction ID ${importTransactionId}.`);
285
+ }
286
+
287
+ // Create the headers for the export file
288
+ const headers = [
289
+ 'rowNumber', // Row number in the import file
290
+ 'errorMessage', // Error message for the failed record
291
+ 'errorTrace', // Error trace for debugging
292
+ ...Object.keys(firstErrorLogEntry.rowData ? JSON.parse(firstErrorLogEntry.rowData) : {}), // Include all keys from the rowData JSON
293
+ ];
294
+
295
+
296
+ // Depending upon the format of the import tranaction file, create a readable stream of the error log entries
297
+ const importTransaction = await this.loadImportTransaction(importTransactionId);
298
+ const importFileMediaObject = this.getImportFileObject(importTransaction);
299
+ const mimeType = importFileMediaObject.mimeType;
300
+ const templateFormat = mimeType === ImportMimeTypes.CSV ? "csv" : "excel";
301
+ const dataRecordsFunc = async (chunkIndex: number, chunkSize: number): Promise<any[]> => {
302
+ // Get the error log entries for the import transaction
303
+ const errorLogEntries = await this.entityManager.getRepository(ImportTransactionErrorLog).find({
304
+ where: {
305
+ importTransaction: { id: importTransactionId },
306
+ },
307
+ skip: chunkIndex * chunkSize,
308
+ take: chunkSize,
309
+ });
310
+
311
+ if (!errorLogEntries || errorLogEntries.length === 0) {
312
+ return []; // Return an empty array if no error log entries found
313
+ }
314
+
315
+ // Read the row data json from the error log entry, parse it and write it as a record to the stream
316
+ return errorLogEntries.map(entry => {
317
+ const rowData = entry.rowData ? JSON.parse(entry.rowData) : {};
318
+ return {
319
+ rowNumber: entry.rowNumber,
320
+ errorMessage: entry.errorMessage,
321
+ errorTrace: entry.errorTrace,
322
+ ...rowData, // Spread the row data into the record
323
+ };
324
+ });
325
+ };
326
+
327
+ // Get the export stream for the failed records
328
+ const exportStream = await this.getFailedRecordsStream(templateFormat, headers, dataRecordsFunc);
329
+ if (!exportStream) {
330
+ throw new BadRequestException(`Failed to create export stream for import transaction ID ${importTransactionId}.`);
331
+ }
332
+ // Return the export stream
333
+ return {
334
+ stream: exportStream,
335
+ fileName: `${importTransaction.modelMetadata.singularName}-failed-imports.${templateFormat}`,
336
+ mimeType: templateFormat === "excel" ? ImportMimeTypes.EXCEL : ImportMimeTypes.CSV,
337
+ };
338
+
339
+ }
340
+
341
+ private async getFailedRecordsStream(templateFormat: string, headers: string[], dataRecordsFunc: (chunkIndex: number, chunkSize: number) => Promise<any[]>) {
342
+ let exportStream = null;
343
+ switch (templateFormat) {
344
+ case "excel":
345
+ exportStream = await this.excelService.createExcelStream(dataRecordsFunc, 100, headers);
346
+ break;
347
+ case "csv":
348
+ exportStream = await this.csvService.createCsvStream(dataRecordsFunc, 100, headers);
349
+ break;
350
+ default:
351
+ throw new Error('Invalid export format');
352
+ }
353
+ return exportStream;
354
+ }
355
+
356
+
264
357
  private async loadImportTransaction(importTransactionId: number) {
265
358
  const importTransaction = await this.findOne(importTransactionId, {
266
359
  populate: ['modelMetadata', 'modelMetadata.fields'],
@@ -329,48 +422,96 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
329
422
  return fileUrlResponse.data;
330
423
  }
331
424
 
332
- private async writeFileRecordsToDb(
425
+ private async importFromFileToDB(
426
+ importTransaction: ImportTransaction,
333
427
  importFileStream: Readable,
334
428
  mimeType: string,
335
- mapping: ImportMapping[],
336
- modelMetadataWithFields: ModelMetadata,
337
- ): Promise<Array<number>> {
338
- // Get the model service for the model metadata name
339
- const modelService = this.getModelService(modelMetadataWithFields.singularName);
429
+ ): Promise<ImportRecordsResult> {
430
+ if (!importTransaction.modelMetadata) {
431
+ throw new Error(`Model metadata for import transaction ID ${importTransaction.id} not found.`);
432
+ }
433
+
340
434
  const createdRecordIds = [];
341
- // Depending upon the mime type of the file, read the file in pages
342
- // For CSV files, use the csvService to read the file in pages
343
- // For Excel files, use the excelService to read the file in pages
344
- if (mimeType === 'text/csv') {
345
- // Read the csv file in pages
435
+ const createdErrorLogIds = [];
436
+
437
+ // Get the model service for the model metadata name
438
+ const modelService = this.getModelService(importTransaction.modelMetadata.singularName);
439
+
440
+ // Depending upon the mime type of the file, read the file in pages and insert the records into the database
441
+ if (mimeType === ImportMimeTypes.CSV) {
346
442
  for await (const page of this.csvService.readCsvInPagesFromStream(importFileStream)) {
347
- // Convert the paginated result to DTOs
348
- const dtos = await this.convertPaginatedResultToDtos(page, modelMetadataWithFields, mapping);
349
- // Use the model service to create the records in the database
350
- const createdRecords = await modelService.insertMany(dtos, [], {});
351
- // Set the solidRequestContext to null, as this is a background job;
352
- // Return the IDs of the created records
353
- const newIds = createdRecords.map(record => record.id);
354
- // Add the new IDs to the createdRecordIds array
355
- createdRecordIds.push(...newIds);
443
+ const { ids, errorLogIds } = await this.importRecords(page, importTransaction, modelService);
444
+ createdRecordIds.push(...ids);
445
+ createdErrorLogIds.push(...errorLogIds);
356
446
  }
357
447
  }
358
- else if (mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
359
- // Read the excel file in pages
448
+ else if (mimeType === ImportMimeTypes.EXCEL) {
360
449
  for await (const page of this.excelService.readExcelInPagesFromStream(importFileStream)) {
361
- // Convert the paginated result to DTOs
362
- const dtos = await this.convertPaginatedResultToDtos(page, modelMetadataWithFields, mapping);
363
- // Use the model service to create the records in the database
364
- const createdRecords = await modelService.insertMany(dtos, [], {});
365
- // Set the solidRequestContext to null, as this is a background job;
366
- // Return the IDs of the created records
367
- const newIds = createdRecords.map(record => record.id);
368
- createdRecordIds.push(...newIds);
450
+ const { ids, errorLogIds } = await this.importRecords(page, importTransaction, modelService);
451
+ createdRecordIds.push(...ids);
452
+ createdErrorLogIds.push(...errorLogIds);
369
453
  }
370
454
  } else { // If the file is neither CSV nor Excel, throw an error
371
455
  throw new Error(`Unsupported file type: ${mimeType}`);
372
456
  }
373
- return createdRecordIds; // Return the IDs of the created records
457
+
458
+ return {
459
+ ids: createdRecordIds, // Return the IDs of the created records
460
+ errorLogIds: createdErrorLogIds, // Return the IDs of the error log entries created during the import
461
+ }
462
+ }
463
+
464
+ private async importRecords(page: ImportPaginatedReadResult, importTransaction: ImportTransaction, modelService: CRUDService<any>): Promise<ImportRecordsResult> {
465
+ if (!importTransaction.modelMetadata || !importTransaction.modelMetadata.fields) {
466
+ throw new Error(`Model metadata with fields for import transaction ID ${importTransaction.id} not found.`);
467
+ }
468
+
469
+ const ids: Array<number> = [];
470
+ const errorLogIds: Array<number> = [];
471
+ for (const record of page.data) {
472
+ try {
473
+ const createdRecord = await this.insertRecord(record, JSON.parse(importTransaction.mapping) as ImportMapping[], importTransaction.modelMetadata, modelService);
474
+ ids.push(createdRecord.id); // Add the ID of the created record to the ids array
475
+ }
476
+ catch (error) {
477
+ this.logger.debug(`Error inserting record: ${JSON.stringify(record)}. Error: ${error.message}`);
478
+ // Get the Import transaction error log repo
479
+ const errorLog = await this.createErrorLogEntry(importTransaction, record, error);
480
+ errorLogIds.push(errorLog.id); // Add the ID of the error log entry to the errorLogIds array
481
+ }
482
+ }
483
+ return {
484
+ ids: ids, // Return the IDs of the created records
485
+ errorLogIds: errorLogIds, // Return the IDs of the error log entries created during the import
486
+ };
487
+ }
488
+
489
+ private async createErrorLogEntry(importTransaction: ImportTransaction, record: Record<string, any>, error: any) {
490
+ const importTransactionRepo = this.entityManager.getRepository(ImportTransactionErrorLog);
491
+ // Create a new ImportTransactionErrorLog entry
492
+ const rowNumber = uuidv4(); // Generate a unique row number or use page.rowNumber if available
493
+
494
+ const errorLogEntry = {
495
+ importTransactionErrorLogId: `${importTransaction.id}-${rowNumber}`, // FIXME pending to retrieve the row number from the page
496
+ rowNumber: 1, // FIXME pending to retrieve the row number from the page
497
+ rowData: JSON.stringify(record), // Store the row data
498
+ importTransaction: importTransaction, // Link to the import transaction
499
+ errorMessage: error.message, // Store the error message
500
+ errorTrace: error.stack || '', // Store the error stack trace if available
501
+ } as ImportTransactionErrorLog;
502
+
503
+ // Save the error log entry to the database
504
+ const savedEntry = await importTransactionRepo.save(errorLogEntry);
505
+ return savedEntry; // Return the ID of the saved error log entry
506
+ }
507
+
508
+ //FIXME Currently below method fails if any field in the record is not valid or if the record is not valid. It does not collect the errors for all fields in a record
509
+ private async insertRecord(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata, modelService: CRUDService<any>): Promise<any> {
510
+ // Convert the imported record to a DTO
511
+ const dto = await this.convertImportedRecordToDto(record, mapping, modelMetadataWithFields);
512
+ // Use the model service to create the record in the database
513
+ const createdRecord = await modelService.create(dto, [], {}); //FIXME: Need to handle this part alongwith the refactoring of the CRUDService for permissions
514
+ return createdRecord; // Return the created record
374
515
  }
375
516
 
376
517
  private getModelService(modelSingularName: string): CRUDService<any> {
@@ -383,22 +524,11 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
383
524
  return modelService;
384
525
  }
385
526
 
386
- // This method will
387
- private async convertPaginatedResultToDtos(importPaginatedResult: ImportPaginatedReadResult, modelMetadataWithFields: ModelMetadata, mapping: ImportMapping[]) {
388
- const dtos = [];
389
- // Iterate through the data records in the importPaginatedResult
390
- for (const record of importPaginatedResult.data) {
391
- // For every key in the record, get the corresponding field from the mapping, if the field is not found in mapping, skip the field
392
- const dto = await this.convertImportedRecordToDto(record, mapping, modelMetadataWithFields);
393
- dtos.push(dto);
394
- }
395
- return dtos;
396
- }
397
-
398
527
  private async convertImportedRecordToDto(record: Record<string, any>, mapping: ImportMapping[], modelMetadataWithFields: ModelMetadata) {
399
528
  // Create a new record object
400
529
  const dtoRecord: Record<string, any> = {};
401
530
 
531
+ // Iterate through every cell in the record
402
532
  // Using the saved mapping, populate the dtoRecord w.r.t the record and fields
403
533
  for (const key in record) {
404
534
  const mappedField = mapping.find(m => m.header === key);
@@ -408,7 +538,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
408
538
  // const userKeyField = modelMetadataWithFields.fields.find(f => f.isUserKey === true); // Assuming userKey is a field in the model metadata
409
539
  if (fieldMetadata) {
410
540
  // If the field is found in the model metadata, set the value in the dtoRecord
411
- await this.populateDto(dtoRecord, fieldMetadata, record, key);
541
+ await this.populateDtoForACell(dtoRecord, fieldMetadata, record, key);
412
542
  } else {
413
543
  this.logger.warn(`Field ${mappedField.fieldName} not found in model metadata ${modelMetadataWithFields.singularName}`);
414
544
  }
@@ -417,7 +547,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
417
547
  return dtoRecord;
418
548
  }
419
549
 
420
- private async populateDto(dtoRecord: Record<string, any>, fieldMetadata: FieldMetadata, record: Record<string, any>, key: string): Promise<Record<string, any>> {
550
+ private async populateDtoForACell(dtoRecord: Record<string, any>, fieldMetadata: FieldMetadata, record: Record<string, any>, key: string): Promise<Record<string, any>> {
421
551
  const fieldType = fieldMetadata.type;
422
552
  // const userKeyFieldName = userKeyField?.name || 'id'; // Default to 'id' if not found
423
553
 
@@ -426,14 +556,14 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
426
556
  case SolidFieldType.relation: {
427
557
  return await this.populateDtoForRelations(fieldMetadata, record, key, dtoRecord);
428
558
  }
429
- case SolidFieldType.date:
559
+ case SolidFieldType.date:
430
560
  case SolidFieldType.datetime: return this.populateDtoForDate(record, key, fieldMetadata, dtoRecord);
431
561
  case SolidFieldType.int:
432
562
  case SolidFieldType.bigint:
433
563
  case SolidFieldType.decimal:
434
- return this.populateDtoForNumber(dtoRecord, fieldMetadata, record, key);
564
+ return this.populateDtoForNumber(dtoRecord, fieldMetadata, record, key);
435
565
  case SolidFieldType.boolean:
436
- return this.populateDtoForBoolean(dtoRecord, fieldMetadata, record, key);
566
+ return this.populateDtoForBoolean(dtoRecord, fieldMetadata, record, key);
437
567
  default:
438
568
  dtoRecord[fieldMetadata.name] = record[key];
439
569
  return dtoRecord;
@@ -443,7 +573,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
443
573
  private populateDtoForBoolean(dtoRecord: Record<string, any>, fieldMetadata: FieldMetadata, record: Record<string, any>, key: string) {
444
574
  const booleanValue = Boolean(record[key]);
445
575
  if (typeof booleanValue !== 'boolean') {
446
- throw new Error(`Invalid boolean value for field ${fieldMetadata.name}: ${record[key]}`);
576
+ throw new Error(`Invalid boolean value for cell ${key} with value ${record[key]}`);
447
577
  }
448
578
  dtoRecord[fieldMetadata.name] = booleanValue;
449
579
  return dtoRecord;
@@ -452,7 +582,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
452
582
  private populateDtoForNumber(dtoRecord: Record<string, any>, fieldMetadata: FieldMetadata, record: Record<string, any>, key: string) {
453
583
  const numberValue = Number(record[key]);
454
584
  if (isNaN(numberValue)) {
455
- throw new Error(`Invalid number value for field ${fieldMetadata.name}: ${record[key]}`);
585
+ throw new Error(`Invalid number value for cell ${key} with value ${record[key]}`);
456
586
  }
457
587
  dtoRecord[fieldMetadata.name] = numberValue;
458
588
  return dtoRecord;
@@ -462,7 +592,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
462
592
  {
463
593
  const dateValue = new Date(record[key]);
464
594
  if (isNaN(dateValue.getTime())) {
465
- throw new Error(`Invalid date value for field ${fieldMetadata.name}: ${record[key]}`);
595
+ throw new Error(`Invalid date value for cell ${key} with value ${record[key]}`);
466
596
  }
467
597
  dtoRecord[fieldMetadata.name] = dateValue;
468
598
  return dtoRecord;
@@ -511,7 +641,7 @@ export class ImportTransactionService extends CRUDService<ImportTransaction> {
511
641
  // From the userKeys, we will get the IDs of the related records using the userKeyFieldName and throw an error if any of the userKeys is not found
512
642
  const relatedRecordsResult = await coModelService.find(relationFilterDto);
513
643
  if (!relatedRecordsResult || !relatedRecordsResult.records || relatedRecordsResult.records.length === 0 || relatedRecordsResult.records.length !== relationUserKeys.length) {
514
- throw new Error(`Missing related records found for userKeys: ${relationUserKeys.join(', ')} in model ${fieldMetadata.relationCoModelSingularName}`);
644
+ throw new Error(`Invalid related records userKey values found for cell ${key} with value ${record[key]}`);
515
645
  }
516
646
  const relatedRecordsIds = relatedRecordsResult.records.map(record => record.id);
517
647
  return relatedRecordsIds;