260228:1412 20260228: setup n8n
All checks were successful
Build and Deploy / deploy (push) Successful in 2m49s

This commit is contained in:
admin
2026-02-28 14:12:48 +07:00
parent 9ddafbb1ac
commit 276d06e950
27 changed files with 3434 additions and 2313 deletions

View File

@@ -76,7 +76,7 @@ describe('CorrespondenceController', () => {
const createDto = {
projectId: 1,
typeId: 1,
title: 'Test Subject',
subject: 'Test Subject',
};
const result = await controller.create(

View File

@@ -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',

View File

@@ -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>;
}

View File

@@ -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;
}

View 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
);
});
});

View 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);
}
}

View 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 {}

View 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();
});
});

View 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();
}
}
}

View File

@@ -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();
});

View File

@@ -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
);
});