260312:0932 20260312:0930 n8n workflow, backend and frontend MOD.
Build and Deploy / deploy (push) Failing after 3m34s

This commit is contained in:
admin
2026-03-12 09:32:46 +07:00
parent 9c0978f3fa
commit 4288f89d8b
15 changed files with 1091 additions and 1010 deletions
@@ -13,7 +13,7 @@ export class ImportCorrespondenceDto {
@IsString()
@IsNotEmpty()
title!: string;
subject!: string;
@IsString()
@IsNotEmpty()
@@ -0,0 +1,27 @@
import { IsOptional, IsEnum, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { MigrationReviewStatus } from '../entities/migration-review-queue.entity';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PaginationDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 10;
}
export class MigrationQueueQueryDto extends PaginationDto {
@ApiPropertyOptional({ enum: MigrationReviewStatus })
@IsOptional()
@IsEnum(MigrationReviewStatus)
status?: MigrationReviewStatus;
}
@@ -0,0 +1,44 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
} from 'typeorm';
export enum MigrationErrorType {
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
AI_PARSE_ERROR = 'AI_PARSE_ERROR',
API_ERROR = 'API_ERROR',
DB_ERROR = 'DB_ERROR',
SECURITY = 'SECURITY',
UNKNOWN = 'UNKNOWN',
}
@Entity('migration_errors')
export class MigrationError {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'batch_id', length: 50, nullable: true })
batchId?: string;
@Column({ name: 'document_number', length: 100, nullable: true })
documentNumber?: string;
@Column({
name: 'error_type',
type: 'enum',
enum: MigrationErrorType,
nullable: true,
})
errorType?: MigrationErrorType;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage?: string;
@Column({ name: 'raw_ai_response', type: 'text', nullable: true })
rawAiResponse?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,61 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
} from 'typeorm';
export enum MigrationReviewStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity('migration_review_queue')
export class MigrationReviewQueue {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'document_number', length: 100, unique: true })
documentNumber!: string;
@Column({ type: 'text', nullable: true })
title?: string;
@Column({ name: 'original_title', type: 'text', nullable: true })
originalTitle?: string;
@Column({ name: 'ai_suggested_category', length: 50, nullable: true })
aiSuggestedCategory?: string;
@Column({
name: 'ai_confidence',
type: 'decimal',
precision: 4,
scale: 3,
nullable: true,
})
aiConfidence?: number;
@Column({ name: 'ai_issues', type: 'json', nullable: true })
aiIssues?: any;
@Column({ name: 'review_reason', length: 255, nullable: true })
reviewReason?: string;
@Column({
type: 'enum',
enum: MigrationReviewStatus,
default: MigrationReviewStatus.PENDING,
})
status!: MigrationReviewStatus;
@Column({ name: 'reviewed_by', length: 100, nullable: true })
reviewedBy?: string;
@Column({ name: 'reviewed_at', type: 'timestamp', nullable: true })
reviewedAt?: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -3,7 +3,10 @@ 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';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader, ApiQuery, ApiParam } from '@nestjs/swagger';
import { MigrationQueueQueryDto } from './dto/migration-queue-query.dto';
import { Get, Param, Query, Res, ParseIntPipe, Body, Headers, Post, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
@ApiTags('Migration')
@ApiBearerAuth()
@@ -27,4 +30,79 @@ export class MigrationController {
const userId = user?.id || user?.userId || 5;
return this.migrationService.importCorrespondence(dto, idempotencyKey, userId);
}
@Get('queue')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get migration review queue' })
async getReviewQueue(@Query() query: MigrationQueueQueryDto) {
return this.migrationService.getReviewQueue(query);
}
@Get('queue/:id')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a specific queue item by ID' })
@ApiParam({ name: 'id', type: Number })
async getQueueItemById(@Param('id', ParseIntPipe) id: number) {
return this.migrationService.getQueueItemById(id);
}
@Get('errors')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get migration errors' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getErrors(
@Query('page') page?: number,
@Query('limit') limit?: number
) {
return this.migrationService.getErrors(page, limit);
}
@Post('queue/:id/approve')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Approve and import a queued migration item' })
@ApiParam({ name: 'id', type: Number })
@ApiHeader({
name: 'Idempotency-Key',
description: 'Unique key per document and batch to prevent duplicate inserts',
required: true,
})
async approveQueueItem(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ImportCorrespondenceDto,
@Headers('idempotency-key') idempotencyKey: string,
@CurrentUser() user: any
) {
const userId = user?.id || user?.userId || 5;
return this.migrationService.approveQueueItem(id, dto, idempotencyKey, userId);
}
@Post('queue/:id/reject')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Reject a queued migration item' })
@ApiParam({ name: 'id', type: Number })
async rejectQueueItem(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: any
) {
const userId = user?.id || user?.userId || 5;
return this.migrationService.rejectQueueItem(id, userId);
}
@Get('staging-file')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Stream a file from staging' })
@ApiQuery({ name: 'path', required: true, type: String })
async getStagingFile(
@Query('path') filePath: string,
@Res() res: Response
) {
const stream = this.migrationService.getStagingFileStream(filePath);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="document.pdf"',
});
stream.pipe(res);
}
}
@@ -10,10 +10,15 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence-
import { Project } from '../project/entities/project.entity';
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
import { MigrationReviewQueue } from './entities/migration-review-queue.entity';
import { MigrationError } from './entities/migration-error.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
ImportTransaction,
MigrationReviewQueue,
MigrationError,
Correspondence,
CorrespondenceRevision,
CorrespondenceType,
@@ -15,7 +15,14 @@ import { CorrespondenceType } from '../correspondence/entities/correspondence-ty
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { Project } from '../project/entities/project.entity';
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import {
MigrationReviewQueue,
MigrationReviewStatus,
} from './entities/migration-review-queue.entity';
import { MigrationError } from './entities/migration-error.entity';
import { MigrationQueueQueryDto } from './dto/migration-queue-query.dto';
import { createReadStream, existsSync } from 'fs';
import * as path from 'path';
@Injectable()
export class MigrationService {
private readonly logger = new Logger(MigrationService.name);
@@ -30,6 +37,10 @@ export class MigrationService {
private readonly correspondenceStatusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
@InjectRepository(MigrationReviewQueue)
private readonly reviewQueueRepo: Repository<MigrationReviewQueue>,
@InjectRepository(MigrationError)
private readonly errorRepo: Repository<MigrationError>,
private readonly fileStorageService: FileStorageService
) {}
@@ -202,7 +213,7 @@ export class MigrationService {
revisionLabel: revNum === 0 ? '0' : revNum.toString(),
isCurrent: true,
statusId: status.id,
subject: dto.title,
subject: dto.subject,
description: 'Migrated from legacy system via Auto Ingest',
body: dto.body || undefined,
documentDate: parseDateStr(dto.document_date || dto.issued_date),
@@ -248,6 +259,50 @@ export class MigrationService {
await queryRunner.manager.save('RfaRevision', rfaRev);
}
// 5.5 Handle Tags
if (
dto.details &&
Array.isArray(dto.details.tags) &&
dto.details.tags.length > 0
) {
for (const tagItem of dto.details.tags) {
let tagName: string | undefined;
if (typeof tagItem === 'string') {
tagName = tagItem;
} else if (tagItem && typeof tagItem === 'object') {
const tObj = tagItem as { tag_name?: unknown };
if (typeof tObj.tag_name === 'string') {
tagName = tObj.tag_name;
}
}
if (!tagName) continue;
// Find or create Tag
const tagRes = (await queryRunner.manager.query(
'SELECT id FROM tags WHERE project_id = ? AND tag_name = ? LIMIT 1',
[project.id, tagName]
)) as Array<{ id: number }>;
let tagId: number;
if (tagRes && tagRes.length > 0) {
tagId = tagRes[0].id;
} else {
const insertRes = (await queryRunner.manager.query(
"INSERT INTO tags (project_id, tag_name, color_code, created_by) VALUES (?, ?, 'default', ?)",
[project.id, tagName, userId]
)) as { insertId: number };
tagId = insertRes.insertId;
}
// Link to correspondence
await queryRunner.manager.query(
'INSERT IGNORE INTO correspondence_tags (correspondence_id, tag_id) VALUES (?, ?)',
[correspondence.id, tagId]
);
}
}
// 6. Track Transaction
const transaction = queryRunner.manager.create(ImportTransaction, {
idempotencyKey,
@@ -295,4 +350,105 @@ export class MigrationService {
await queryRunner.release();
}
}
async getReviewQueue(query: MigrationQueueQueryDto) {
const { page = 1, limit = 10, status } = query;
const skip = (page - 1) * limit;
const queryBuilder = this.reviewQueueRepo.createQueryBuilder('queue');
if (status) {
queryBuilder.where('queue.status = :status', { status });
}
queryBuilder.orderBy('queue.createdAt', 'DESC');
queryBuilder.skip(skip).take(limit);
const [items, total] = await queryBuilder.getManyAndCount();
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async getQueueItemById(id: number) {
const item = await this.reviewQueueRepo.findOne({ where: { id } });
if (!item) {
throw new BadRequestException(`Queue item with ID ${id} not found`);
}
return item;
}
async getErrors(page: number = 1, limit: number = 10) {
const skip = (page - 1) * limit;
const [items, total] = await this.errorRepo.findAndCount({
order: { createdAt: 'DESC' },
skip,
take: limit,
});
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async approveQueueItem(id: number, dto: ImportCorrespondenceDto, idempotencyKey: string, userId: number) {
const queueItem = await this.reviewQueueRepo.findOne({ where: { id } });
if (!queueItem) {
throw new BadRequestException('Queue item not found');
}
if (queueItem.status !== MigrationReviewStatus.PENDING) {
throw new BadRequestException(`Queue item is already ${queueItem.status}`);
}
// Attempt the import
const result = await this.importCorrespondence(dto, idempotencyKey, userId);
// If successful, update the queue item status
queueItem.status = MigrationReviewStatus.APPROVED;
queueItem.reviewedBy = userId.toString();
queueItem.reviewedAt = new Date();
await this.reviewQueueRepo.save(queueItem);
return result;
}
async rejectQueueItem(id: number, userId: number) {
const queueItem = await this.reviewQueueRepo.findOne({ where: { id } });
if (!queueItem) {
throw new BadRequestException('Queue item not found');
}
queueItem.status = MigrationReviewStatus.REJECTED;
queueItem.reviewedBy = userId.toString();
queueItem.reviewedAt = new Date();
await this.reviewQueueRepo.save(queueItem);
return {
message: 'Document rejected successfully',
id: queueItem.id,
};
}
getStagingFileStream(filePath: string) {
if (!filePath) {
throw new BadRequestException('File path is required');
}
const resolvedPath = path.resolve(filePath);
if (!existsSync(resolvedPath)) {
throw new BadRequestException('File not found at specified path');
}
return createReadStream(resolvedPath);
}
}