260228:1412 20260228: setup n8n
All checks were successful
Build and Deploy / deploy (push) Successful in 2m49s
All checks were successful
Build and Deploy / deploy (push) Successful in 2m49s
This commit is contained in:
@@ -76,7 +76,7 @@ describe('CorrespondenceController', () => {
|
||||
const createDto = {
|
||||
projectId: 1,
|
||||
typeId: 1,
|
||||
title: 'Test Subject',
|
||||
subject: 'Test Subject',
|
||||
};
|
||||
|
||||
const result = await controller.create(
|
||||
|
||||
@@ -41,9 +41,9 @@ describe('ManualOverrideService', () => {
|
||||
originatorOrganizationId: 2,
|
||||
recipientOrganizationId: 3,
|
||||
correspondenceTypeId: 4,
|
||||
subTypeId: null,
|
||||
rfaTypeId: null,
|
||||
disciplineId: null,
|
||||
subTypeId: 5,
|
||||
rfaTypeId: 6,
|
||||
disciplineId: 7,
|
||||
resetScope: 'YEAR_2024',
|
||||
newLastNumber: 999,
|
||||
reason: 'System sync',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ImportCorrespondenceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
document_number!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
category!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
source_file_path!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
ai_confidence?: number;
|
||||
|
||||
@IsOptional()
|
||||
ai_issues?: any;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
migrated_by!: string; // "SYSTEM_IMPORT"
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
batch_id!: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('import_transactions')
|
||||
export class ImportTransaction {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Index('idx_idem_key', { unique: true })
|
||||
@Column({ name: 'idempotency_key', length: 255, unique: true })
|
||||
idempotencyKey!: string;
|
||||
|
||||
@Column({ name: 'document_number', length: 100, nullable: true })
|
||||
documentNumber!: string;
|
||||
|
||||
@Column({ name: 'batch_id', length: 100, nullable: true })
|
||||
batchId!: string;
|
||||
|
||||
@Column({ name: 'status_code', default: 201 })
|
||||
statusCode!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
58
backend/src/modules/migration/migration.controller.spec.ts
Normal file
58
backend/src/modules/migration/migration.controller.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { MigrationController } from './migration.controller';
|
||||
import { MigrationService } from './migration.service';
|
||||
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||
|
||||
describe('MigrationController', () => {
|
||||
let controller: MigrationController;
|
||||
let service: MigrationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [MigrationController],
|
||||
providers: [
|
||||
{
|
||||
provide: MigrationService,
|
||||
useValue: {
|
||||
importCorrespondence: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ message: 'Success' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<MigrationController>(MigrationController);
|
||||
service = module.get<MigrationService>(MigrationService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call importCorrespondence on service', async () => {
|
||||
const dto: ImportCorrespondenceDto = {
|
||||
document_number: 'DOC-001',
|
||||
title: 'Legacy Record',
|
||||
category: 'Correspondence',
|
||||
source_file_path: '/staging_ai/test.pdf',
|
||||
migrated_by: 'SYSTEM_IMPORT',
|
||||
batch_id: 'batch1',
|
||||
};
|
||||
|
||||
const idempotencyKey = 'key123';
|
||||
const user = { userId: 5 };
|
||||
|
||||
const result = await controller.importCorrespondence(
|
||||
dto,
|
||||
idempotencyKey,
|
||||
user
|
||||
);
|
||||
expect(result).toEqual({ message: 'Success' });
|
||||
expect(service.importCorrespondence).toHaveBeenCalledWith(
|
||||
dto,
|
||||
idempotencyKey,
|
||||
5
|
||||
);
|
||||
});
|
||||
});
|
||||
30
backend/src/modules/migration/migration.controller.ts
Normal file
30
backend/src/modules/migration/migration.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller, Post, Body, Headers, UseGuards } from '@nestjs/common';
|
||||
import { MigrationService } from './migration.service';
|
||||
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Migration')
|
||||
@ApiBearerAuth()
|
||||
@Controller('migration')
|
||||
export class MigrationController {
|
||||
constructor(private readonly migrationService: MigrationService) {}
|
||||
|
||||
@Post('import')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Import generic legacy correspondence record via n8n integration' })
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key per document and batch to prevent duplicate inserts',
|
||||
required: true,
|
||||
})
|
||||
async importCorrespondence(
|
||||
@Body() dto: ImportCorrespondenceDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string,
|
||||
@CurrentUser() user: any
|
||||
) {
|
||||
const userId = user?.id || user?.userId || 5;
|
||||
return this.migrationService.importCorrespondence(dto, idempotencyKey, userId);
|
||||
}
|
||||
}
|
||||
28
backend/src/modules/migration/migration.module.ts
Normal file
28
backend/src/modules/migration/migration.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MigrationController } from './migration.controller';
|
||||
import { MigrationService } from './migration.service';
|
||||
import { ImportTransaction } from './entities/import-transaction.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
// Import any other required modules for JwtAuthGuard (usually AuthModule or similar, but global guards handle this mostly)
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
ImportTransaction,
|
||||
Correspondence,
|
||||
CorrespondenceRevision,
|
||||
CorrespondenceType,
|
||||
CorrespondenceStatus,
|
||||
Project,
|
||||
]),
|
||||
],
|
||||
controllers: [MigrationController],
|
||||
providers: [MigrationService],
|
||||
exports: [MigrationService],
|
||||
})
|
||||
export class MigrationModule {}
|
||||
83
backend/src/modules/migration/migration.service.spec.ts
Normal file
83
backend/src/modules/migration/migration.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { MigrationService } from './migration.service';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ImportTransaction } from './entities/import-transaction.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
describe('MigrationService', () => {
|
||||
let service: MigrationService;
|
||||
|
||||
const mockTransactionRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTypeRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockStatusRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockProjectRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
count: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MigrationService,
|
||||
{
|
||||
provide: getRepositoryToken(ImportTransaction),
|
||||
useValue: mockTransactionRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceType),
|
||||
useValue: mockTypeRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceStatus),
|
||||
useValue: mockStatusRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Project),
|
||||
useValue: mockProjectRepo,
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MigrationService>(MigrationService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
244
backend/src/modules/migration/migration.service.ts
Normal file
244
backend/src/modules/migration/migration.service.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||
import { ImportTransaction } from './entities/import-transaction.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class MigrationService {
|
||||
private readonly logger = new Logger(MigrationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@InjectRepository(ImportTransaction)
|
||||
private readonly importTransactionRepo: Repository<ImportTransaction>,
|
||||
@InjectRepository(CorrespondenceType)
|
||||
private readonly correspondenceTypeRepo: Repository<CorrespondenceType>,
|
||||
@InjectRepository(CorrespondenceStatus)
|
||||
private readonly correspondenceStatusRepo: Repository<CorrespondenceStatus>,
|
||||
@InjectRepository(Project)
|
||||
private readonly projectRepo: Repository<Project>,
|
||||
private readonly fileStorageService: FileStorageService
|
||||
) {}
|
||||
|
||||
async importCorrespondence(
|
||||
dto: ImportCorrespondenceDto,
|
||||
idempotencyKey: string,
|
||||
userId: number
|
||||
) {
|
||||
if (!idempotencyKey) {
|
||||
throw new BadRequestException('Idempotency-Key header is required');
|
||||
}
|
||||
|
||||
// 1. Idempotency Check
|
||||
const existingTransaction = await this.importTransactionRepo.findOne({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
|
||||
if (existingTransaction) {
|
||||
if (existingTransaction.statusCode === 201) {
|
||||
this.logger.log(
|
||||
`Idempotency key ${idempotencyKey} already processed. Returning cached success.`
|
||||
);
|
||||
return {
|
||||
message: 'Already processed',
|
||||
transaction: existingTransaction,
|
||||
};
|
||||
} else {
|
||||
throw new ConflictException(
|
||||
`Transaction failed previously with status ${existingTransaction.statusCode}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Dependencies
|
||||
const type = await this.correspondenceTypeRepo.findOne({
|
||||
where: { typeName: dto.category },
|
||||
});
|
||||
|
||||
// If exact name isn't found, try typeCode just in case
|
||||
const typeId = type
|
||||
? type.id
|
||||
: (
|
||||
await this.correspondenceTypeRepo.findOne({
|
||||
where: { typeCode: dto.category },
|
||||
})
|
||||
)?.id;
|
||||
|
||||
if (!typeId) {
|
||||
throw new BadRequestException(
|
||||
`Category "${dto.category}" not found in system.`
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate documents typically end up as 'Closed by Owner' or a similar terminal state, unless specifically pending.
|
||||
// For legacy, let's use a default terminal status 'CLBOWN' if available. If not, fallback to 'DRAFT'.
|
||||
let status = await this.correspondenceStatusRepo.findOne({
|
||||
where: { statusCode: 'CLBOWN' },
|
||||
});
|
||||
if (!status) {
|
||||
status = await this.correspondenceStatusRepo.findOne({
|
||||
where: { statusCode: 'DRAFT' },
|
||||
});
|
||||
}
|
||||
if (!status) {
|
||||
throw new InternalServerErrorException(
|
||||
'CRITICAL: No default correspondence status found (missing CLBOWN/DRAFT)'
|
||||
);
|
||||
}
|
||||
|
||||
// We assume migration runs for LCBP3 project
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { projectCode: 'LCBP3' },
|
||||
});
|
||||
if (!project) {
|
||||
throw new InternalServerErrorException(
|
||||
'Project LCBP3 not found in database'
|
||||
);
|
||||
}
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 3. Find or Create Correspondence
|
||||
let correspondence = await queryRunner.manager.findOne(Correspondence, {
|
||||
where: {
|
||||
correspondenceNumber: dto.document_number,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!correspondence) {
|
||||
correspondence = queryRunner.manager.create(Correspondence, {
|
||||
correspondenceNumber: dto.document_number,
|
||||
correspondenceTypeId: typeId,
|
||||
projectId: project.id,
|
||||
isInternal: false,
|
||||
createdBy: userId,
|
||||
});
|
||||
await queryRunner.manager.save(correspondence);
|
||||
}
|
||||
|
||||
// 4. File Handling
|
||||
// We will map the source file and create an Attachment record using FileStorageService
|
||||
// For legacy migrations, we pass document_number mapping logic or basic processing
|
||||
let attachmentId: number | null = null;
|
||||
if (dto.source_file_path) {
|
||||
try {
|
||||
const attachment = await this.fileStorageService.importStagingFile(
|
||||
dto.source_file_path,
|
||||
userId,
|
||||
{ documentType: dto.category } // use category from DTO directly
|
||||
);
|
||||
attachmentId = attachment.id;
|
||||
} catch (fileError: unknown) {
|
||||
const errMsg =
|
||||
fileError instanceof Error ? fileError.message : String(fileError);
|
||||
|
||||
this.logger.warn(
|
||||
`Failed to import file for [${dto.document_number}], continuing without attachment: ${errMsg}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create Revision
|
||||
const revisionCount = await queryRunner.manager.count(
|
||||
CorrespondenceRevision,
|
||||
{
|
||||
where: { correspondenceId: correspondence.id },
|
||||
}
|
||||
);
|
||||
|
||||
// Determine revision number. Support mapping multiple batches to the same document number by incrementing revision.
|
||||
const revNum = revisionCount;
|
||||
const revision = queryRunner.manager.create(CorrespondenceRevision, {
|
||||
correspondenceId: correspondence.id,
|
||||
revisionNumber: revNum,
|
||||
revisionLabel: revNum === 0 ? '0' : revNum.toString(),
|
||||
isCurrent: true,
|
||||
statusId: status.id,
|
||||
subject: dto.title,
|
||||
description: 'Migrated from legacy system via Auto Ingest',
|
||||
details: {
|
||||
...dto.details,
|
||||
ai_confidence: dto.ai_confidence,
|
||||
ai_issues: dto.ai_issues as unknown,
|
||||
source_file_path: dto.source_file_path,
|
||||
attachment_id: attachmentId, // Link attachment ID if successful
|
||||
},
|
||||
schemaVersion: 1,
|
||||
createdBy: userId, // Bot ID
|
||||
});
|
||||
|
||||
if (revisionCount > 0) {
|
||||
await queryRunner.manager.update(
|
||||
CorrespondenceRevision,
|
||||
{ correspondenceId: correspondence.id, isCurrent: true },
|
||||
{ isCurrent: false }
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.manager.save(revision);
|
||||
|
||||
// 5. Track Transaction
|
||||
const transaction = queryRunner.manager.create(ImportTransaction, {
|
||||
idempotencyKey,
|
||||
documentNumber: dto.document_number,
|
||||
batchId: dto.batch_id,
|
||||
statusCode: 201,
|
||||
});
|
||||
await queryRunner.manager.save(transaction);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
this.logger.log(
|
||||
`Ingested document [${dto.document_number}] successfully (Batch: ${dto.batch_id})`
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Import successful',
|
||||
correspondenceId: correspondence.id,
|
||||
revisionId: revision.id,
|
||||
transactionId: transaction.id,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
this.logger.error(
|
||||
`Import failed for document [${dto.document_number}]: ${errorMessage}`,
|
||||
errorStack
|
||||
);
|
||||
|
||||
const failedTransaction = this.importTransactionRepo.create({
|
||||
idempotencyKey,
|
||||
documentNumber: dto.document_number,
|
||||
batchId: dto.batch_id,
|
||||
statusCode: 500,
|
||||
});
|
||||
await this.importTransactionRepo.save(failedTransaction).catch(() => {});
|
||||
|
||||
throw new InternalServerErrorException(
|
||||
'Migration import failed: ' + errorMessage
|
||||
);
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ describe('ProjectController', () => {
|
||||
const mockResult = { data: [], meta: {} };
|
||||
(mockProjectService.findAll as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.findAll({});
|
||||
const result = await controller.findAll({ page: 1, limit: 10 });
|
||||
|
||||
expect(mockProjectService.findAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('WorkflowDslParser', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRepository = {
|
||||
save: jest.fn((def) => Promise.resolve(def)),
|
||||
save: jest.fn((def) => Promise.resolve(def)) as unknown as jest.Mock,
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('WorkflowDslParser', () => {
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,7 +184,7 @@ describe('WorkflowDslParser', () => {
|
||||
it('should throw error if definition not found', async () => {
|
||||
mockRepository.findOne = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await expect(parser.getParsedDsl(999)).rejects.toThrow(
|
||||
await expect(parser.getParsedDsl('999')).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user