260312:0932 20260312:0930 n8n workflow, backend and frontend MOD.
Build and Deploy / deploy (push) Failing after 3m34s
Build and Deploy / deploy (push) Failing after 3m34s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user