Low Level Design-(LLD)---fluxx hqx classifier
Low Level Design-(LLD)---fluxx hqx classifier
Service Name: fluxx-hqx-classifier
Version: 1.0.0
Date: March 27, 2026
Status: Draft
Table of Contents
- Repository Structure
- Component Design
- Data Models
- API Implementation
- Service Layer
- Processing Pipeline
- Repository Layer
- Configuration
- Error Handling
- Testing Strategy
- Sequence Diagrams
- Database Schema
Repository Structure
fluxx-hqx-classifier/├── docs/│ ├── openapi.yaml # OpenAPI 3.0.3 specification│ ├── README.md # Service documentation│ └── ops/│ └── runbook.md # Operational runbook├── postman/│ └── collections/│ ├── classify-api.json│ └── environments/│ ├── dev.json│ └── prod.json├── src/│ ├── features/ # Feature-based organization│ │ └── classifications/ # Classification feature│ │ └── v1/ # Version 1 implementation│ │ ├── routes/│ │ │ └── classifications.routes.ts│ │ ├── services/│ │ │ ├── classification.service.ts│ │ │ ├── job-status.service.ts│ │ │ ├── pdf-splitter.service.ts│ │ │ ├── image-converter.service.ts│ │ │ ├── batch-processor.service.ts│ │ │ ├── llm-invocation.service.ts│ │ │ └── result-collator.service.ts│ │ ├── repositories/│ │ │ ├── job.repository.ts│ │ │ └── classification-result.repository.ts│ │ ├── workers/│ │ │ └── classification-worker.ts│ │ ├── models/│ │ │ ├── job.model.ts│ │ │ ├── classification-result.model.ts│ │ │ └── dto/│ │ │ ├── classify-request.dto.ts│ │ │ ├── classify-response.dto.ts│ │ │ ├── status-response.dto.ts│ │ │ └── llm-request.dto.ts│ │ └── middleware/│ │ ├── file-upload.middleware.ts│ │ └── validation.middleware.ts│ ├── shared/ # Shared utilities│ │ ├── config/│ │ │ ├── app-config.json│ │ │ ├── dependency-config.ts│ │ │ └── resilience-config.ts│ │ ├── middleware/│ │ │ └── error-handler.ts│ │ ├── utils/│ │ │ ├── errors.ts│ │ │ ├── validators.ts│ │ │ └── constants.ts│ │ └── types/│ │ ├── fastify.d.ts│ │ └── index.d.ts│ └── index.ts # Application entry point├── tests/│ ├── unit/│ │ ├── services/│ │ ├── repositories/│ │ └── utils/│ ├── integration/│ │ ├── routes/│ │ └── workers/│ └── fixtures/│ ├── sample.pdf│ └── mock-responses.json├── .env.example├── .gitignore├── .eslintrc.json├── .prettierrc├── Dockerfile├── package.json├── tsconfig.json├── vite.config.ts # Vitest configuration└── README.md
Architecture Principles
Feature-Based Organization
This service follows the feature-based organization pattern as specified in API_DEVELOPMENT_STANDARDS.md:
Key Benefits:
- Feature Isolation: All code related to classifications (routes, services, repositories, models) is grouped together
- Version Management: Each feature owns its versions (v1/, v2/), making version upgrades independent
- Team Scalability: Different teams can work on different features without conflicts
- Modular Design: Features can be extracted into separate services if needed
- Clear Boundaries: Business logic is organized by domain, not technical layer
Structure Explanation:
src/features/classifications/v1/├── routes/ # API endpoints for v1├── services/ # Business logic for v1├── repositories/ # Data access for v1├── workers/ # Background jobs for v1├── models/ # Data models and DTOs for v1└── middleware/ # Feature-specific middleware for v1
Shared Components:
src/shared/├── config/ # Application-wide configuration├── middleware/ # Global middleware (error handlers, auth)├── utils/ # Common utilities└── types/ # Shared type definitions
Future Versioning:
When v2 is needed, it can be added as src/features/classifications/v2/ with:
- Independent implementation
- Different data models
- Backward-compatible or breaking changes
- Side-by-side operation with v1
Component Design
1. API Layer Components
ClassificationsRouteHandler
Responsibility: Handle POST /api/v1/Classifications and GET /api/v1/Classifications/{jobId} requests
// src/features/classifications/v1/routes/classifications.routes.tsimport { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';import { ClassificationService } from '../services/classification.service';import { JobStatusService } from '../services/job-status.service';import { ClassifyRequestDTO } from '../models/dto/classify-request.dto';import { Logger } from 'framework-nodejs-extensions';interface ClassificationsRouteHandlerDeps {classificationService: ClassificationService;jobStatusService: JobStatusService;logger: Logger;}export async function classificationsRoutes(fastify: FastifyInstance,opts: ClassificationsRouteHandlerDeps) {const { classificationService, jobStatusService, logger } = opts;// POST /api/v1/Classificationsfastify.post<{Body: ClassifyRequestDTO;}>('/api/v1/Classifications',{schema: {description: 'Submit PDF for asynchronous classification',tags: ['Classification'],consumes: ['multipart/form-data'],body: {type: 'object',required: ['file', 'classificationPrompt'],properties: {file: {type: 'string',format: 'binary',description: 'PDF file to classify',},classificationPrompt: {type: 'string',description: 'LLM prompt for classification',minLength: 10,maxLength: 2000,},},},response: {201: {description: 'Classification job created',type: 'object',properties: {jobId: { type: 'string', format: 'uuid' },status: { type: 'string', enum: ['in-progress'] },createdAt: { type: 'string', format: 'date-time' },statusUrl: { type: 'string' },},},400: { $ref: '#/components/schemas/Error' },413: { $ref: '#/components/schemas/Error' },422: { $ref: '#/components/schemas/Error' },},},config: {authRequired: true,allowedScopes: ['classifier.write'],},},async (request: FastifyRequest, reply: FastifyReply) => {logger.info('Received classification request', {correlationId: request.id,});// Extract file from multipart requestconst data = await request.file();if (!data) {return reply.status(400).send({error: 'MISSING_FILE',message: 'PDF file is required',});}// Validate file typeif (data.mimetype !== 'application/pdf') {return reply.status(400).send({error: 'INVALID_FILE_TYPE',message: 'Only PDF files are supported',});}// Extract classification prompt from fieldsconst fields = data.fields as any;const classificationPrompt = fields?.classificationPrompt?.value;if (!classificationPrompt) {return reply.status(400).send({error: 'MISSING_PROMPT',message: 'classificationPrompt is required',});}// Create classification jobconst result = await classificationService.createJob({fileBuffer: await data.toBuffer(),fileName: data.filename,classificationPrompt,userId: request.user?.id,});reply.status(201).header('Location', `/api/v1/Classifications/${result.jobId}`).send(result);});// GET /api/v1/Classifications/:jobIdfastify.get<{Params: { jobId: string };}>('/api/v1/Classifications/:jobId',{schema: {description: 'Get classification job status',tags: ['Status'],params: {type: 'object',required: ['jobId'],properties: {jobId: {type: 'string',format: 'uuid',description: 'Classification job ID',},},},response: {200: {description: 'Job status retrieved',type: 'object',properties: {jobId: { type: 'string', format: 'uuid' },status: {type: 'string',enum: ['in-progress', 'success', 'failed'],},createdAt: { type: 'string', format: 'date-time' },updatedAt: { type: 'string', format: 'date-time' },completedAt: { type: 'string', format: 'date-time' },result: { type: 'object' },error: { type: 'object' },},},404: { $ref: '#/components/schemas/Error' },},},config: {authRequired: true,allowedScopes: ['classifier.read'],},},async (request: FastifyRequest<{ Params: { jobId: string } }>, reply: FastifyReply) => {const { jobId } = request.params;logger.info('Fetching job status', { jobId });const status = await jobStatusService.getJobStatus(jobId);if (!status) {return reply.status(404).send({error: 'JOB_NOT_FOUND',message: `Job with ID ${jobId} not found`,});}reply.send(status);});}
2. Service Layer Components
ClassificationService
Responsibility: Orchestrate classification job creation and submission
// src/features/classifications/v1/services/classification.service.tsimport { v4 as uuidv4 } from 'uuid';import { Logger, ConfigClient } from 'framework-nodejs-extensions';import { BlobStorageClient } from 'framework-nodejs-storage';import { ServiceBusClient } from 'framework-nodejs-pubsub';import { JobRepository } from '../repositories/job.repository';import { ClassifyRequestDTO } from '../models/dto/classify-request.dto';import { ClassifyResponseDTO } from '../models/dto/classify-response.dto';import { JobStatus } from '../models/job.model';export class ClassificationService {constructor(private logger: Logger,private blobStorageClient: BlobStorageClient,private serviceBusClient: ServiceBusClient,private jobRepository: JobRepository,private configClient: ConfigClient) {}async createJob(request: ClassifyRequestDTO): Promise<ClassifyResponseDTO> {const jobId = uuidv4();const timestamp = new Date();this.logger.info('Creating classification job', { jobId });try {// 1. Validate file sizeconst maxFileSizeMB = await this.configClient.get<number>('MAX_PDF_SIZE_MB') || 50;const fileSizeBytes = request.fileBuffer.length;const fileSizeMB = fileSizeBytes / (1024 * 1024);if (fileSizeMB > maxFileSizeMB) {throw new Error(`File size ${fileSizeMB.toFixed(2)}MB exceeds maximum ${maxFileSizeMB}MB`);}// 2. Upload PDF to blob storageconst blobPath = `uploaded-pdfs/${jobId}/${request.fileName}`;await this.blobStorageClient.upload('hqx-classification-storage',blobPath,request.fileBuffer);this.logger.info('PDF uploaded to blob storage', { jobId, blobPath });// 3. Create job record in databaseawait this.jobRepository.create({jobId,userId: request.userId,fileName: request.fileName,fileSizeBytes,pdfBlobPath: blobPath,classificationPrompt: request.classificationPrompt,status: JobStatus.InProgress,createdAt: timestamp,updatedAt: timestamp,});this.logger.info('Job record created in database', { jobId });// 4. Send message to Service Bus queueconst queueMessage = {jobId,pdfBlobPath: blobPath,classificationPrompt: request.classificationPrompt,createdAt: timestamp.toISOString(),};await this.serviceBusClient.sendMessage('hqx-classification-jobs', queueMessage);this.logger.info('Job message sent to queue', { jobId });// 5. Return responsereturn {jobId,status: 'in-progress',createdAt: timestamp.toISOString(),statusUrl: `/api/v1/Classifications/${jobId}`,};} catch (error) {this.logger.error('Failed to create classification job', { jobId, error });// Attempt cleanupawait this.jobRepository.updateStatus(jobId, JobStatus.Failed, {errorCode: 'JOB_CREATION_FAILED',errorMessage: error.message,});throw error;}}}
JobStatusService
Responsibility: Retrieve and format job status information
// src/features/classifications/v1/services/job-status.service.tsimport { Logger } from 'framework-nodejs-extensions';import { BlobStorageClient } from 'framework-nodejs-storage';import { JobRepository } from '../repositories/job.repository';import { ClassificationResultRepository } from '../repositories/classification-result.repository';import { StatusResponseDTO } from '../models/dto/status-response.dto';import { JobStatus } from '../models/job.model';export class JobStatusService {constructor(private logger: Logger,private jobRepository: JobRepository,private classificationResultRepository: ClassificationResultRepository,private blobStorageClient: BlobStorageClient) {}async getJobStatus(jobId: string): Promise<StatusResponseDTO | null> {this.logger.info('Retrieving job status', { jobId });// Fetch job from databaseconst job = await this.jobRepository.findById(jobId);if (!job) {this.logger.warn('Job not found', { jobId });return null;}// Build base responseconst response: StatusResponseDTO = {jobId: job.jobId,status: job.status,createdAt: job.createdAt.toISOString(),updatedAt: job.updatedAt.toISOString(),};// Add status-specific dataswitch (job.status) {case JobStatus.InProgress:response.progress = {totalPages: job.totalPages || 0,processedPages: job.processedPages || 0,percentComplete: job.totalPages? Math.round((job.processedPages / job.totalPages) * 100): 0,};break;case JobStatus.Success:response.completedAt = job.completedAt?.toISOString();// Fetch classification resultconst result = await this.classificationResultRepository.findByJobId(jobId);if (result) {response.result = result.classificationData;}break;case JobStatus.Failed:response.failedAt = job.failedAt?.toISOString();response.error = {code: job.errorCode,message: job.errorMessage,details: job.errorDetails,};break;}return response;}}
PDFSplitterService
Responsibility: Split PDF into individual pages
// src/features/classifications/v1/services/pdf-splitter.service.tsimport { PDFDocument } from 'pdf-lib';import { Logger } from 'framework-nodejs-extensions';export interface PDFPage {pageNumber: number;pdfBytes: Buffer;}export class PDFSplitterService {constructor(private logger: Logger) {}async splitPDF(pdfBuffer: Buffer): Promise<PDFPage[]> {this.logger.info('Splitting PDF into pages');try {const pdfDoc = await PDFDocument.load(pdfBuffer);const pageCount = pdfDoc.getPageCount();this.logger.info('PDF loaded', { pageCount });const pages: PDFPage[] = [];for (let i = 0; i < pageCount; i++) {// Create new PDF with single pageconst singlePageDoc = await PDFDocument.create();const [copiedPage] = await singlePageDoc.copyPages(pdfDoc, [i]);singlePageDoc.addPage(copiedPage);// Save as bytesconst pdfBytes = await singlePageDoc.save();pages.push({pageNumber: i + 1,pdfBytes: Buffer.from(pdfBytes),});}this.logger.info('PDF split complete', { pageCount: pages.length });return pages;} catch (error) {this.logger.error('Failed to split PDF', { error });throw new Error(`PDF splitting failed: ${error.message}`);}}}
ImageConverterService
Responsibility: Convert PDF pages to images
// src/features/classifications/v1/services/image-converter.service.tsimport { fromBuffer } from 'pdf2pic';import sharp from 'sharp';import { Logger } from 'framework-nodejs-extensions';export interface ConvertedImage {pageNumber: number;imageBuffer: Buffer;format: 'png' | 'jpeg';width: number;height: number;}export class ImageConverterService {constructor(private logger: Logger) {}async convertToImage(pdfBuffer: Buffer,pageNumber: number,options?: { format?: 'png' | 'jpeg'; quality?: number; dpi?: number }): Promise<ConvertedImage> {const { format = 'png', quality = 90, dpi = 150 } = options || {};this.logger.debug('Converting PDF page to image', { pageNumber, format, dpi });try {// Convert PDF to image using pdf2picconst converter = fromBuffer(pdfBuffer, {density: dpi,format: format,quality: quality,width: 2048,height: 2048,});const result = await converter(1, { responseType: 'buffer' });if (!result || !result.buffer) {throw new Error('Image conversion produced no output');}// Optimize with sharpconst optimized = await sharp(result.buffer).resize(1024, 1024, { fit: 'inside', withoutEnlargement: true }).toBuffer();const metadata = await sharp(optimized).metadata();this.logger.debug('Image conversion complete', {pageNumber,width: metadata.width,height: metadata.height,size: optimized.length,});return {pageNumber,imageBuffer: optimized,format,width: metadata.width || 0,height: metadata.height || 0,};} catch (error) {this.logger.error('Failed to convert PDF page to image', { pageNumber, error });throw new Error(`Image conversion failed for page ${pageNumber}: ${error.message}`);}}async convertPages(pdfPages: Buffer[]): Promise<ConvertedImage[]> {const images: ConvertedImage[] = [];for (let i = 0; i < pdfPages.length; i++) {const image = await this.convertToImage(pdfPages[i], i + 1);images.push(image);}return images;}}
BatchProcessorService
Responsibility: Group images into batches and coordinate LLM processing
// src/features/classifications/v1/services/batch-processor.service.tsimport { Logger, ConfigClient } from 'framework-nodejs-extensions';import { LLMInvocationService } from './llm-invocation.service';import { ConvertedImage } from './image-converter.service';export interface BatchResult {batchNumber: number;pageNumbers: number[];classification: any;success: boolean;error?: string;}export class BatchProcessorService {private readonly BATCH_SIZE = 20;constructor(private logger: Logger,private configClient: ConfigClient,private llmInvocationService: LLMInvocationService) {}async processBatches(images: ConvertedImage[],classificationPrompt: string): Promise<BatchResult[]> {this.logger.info('Processing image batches', {totalImages: images.length,batchSize: this.BATCH_SIZE,});const batches = this.createBatches(images);const results: BatchResult[] = [];// Process batches sequentially (can be parallelized with concurrency limit)for (let i = 0; i < batches.length; i++) {const batch = batches[i];const batchNumber = i + 1;this.logger.info('Processing batch', {batchNumber,totalBatches: batches.length,imageCount: batch.length,});try {const classification = await this.llmInvocationService.classifyImages(batch,classificationPrompt);results.push({batchNumber,pageNumbers: batch.map((img) => img.pageNumber),classification,success: true,});this.logger.info('Batch processed successfully', { batchNumber });} catch (error) {this.logger.error('Batch processing failed', { batchNumber, error });results.push({batchNumber,pageNumbers: batch.map((img) => img.pageNumber),classification: null,success: false,error: error.message,});// Optionally retry failed batch// await this.retryBatch(batch, classificationPrompt);}}return results;}private createBatches(images: ConvertedImage[]): ConvertedImage[][] {const batches: ConvertedImage[][] = [];for (let i = 0; i < images.length; i += this.BATCH_SIZE) {batches.push(images.slice(i, i + this.BATCH_SIZE));}this.logger.info('Created batches', {totalImages: images.length,batchCount: batches.length,});return batches;}}
LLMInvocationService
Responsibility: Invoke LLM API for document classification
// src/features/classifications/v1/services/llm-invocation.service.tsimport { Logger, ConfigClient } from 'framework-nodejs-extensions';import { ServiceInvocationClient } from 'framework-nodejs-service-invocation';import { ConvertedImage } from './image-converter.service';export class LLMInvocationService {constructor(private logger: Logger,private configClient: ConfigClient,private httpClient: ServiceInvocationClient) {}async classifyImages(images: ConvertedImage[],classificationPrompt: string): Promise<any> {this.logger.info('Invoking LLM for classification', {imageCount: images.length,});const apiKey = await this.configClient.get<string>('OPENAI_API_KEY');const apiUrl = await this.configClient.get<string>('OPENAI_API_URL');const model = await this.configClient.get<string>('OPENAI_MODEL') || 'gpt-4-vision-preview';// Build LLM requestconst messages = [{role: 'system',content: `You are an expert in HQ Exchange financial document classification. You specialize in identifying and categorizing financial and accounting documents used in audit engagements.Classify the provided document pages into one of these HQ Exchange document types:**Financial Statements:**- Trial Balance- General Ledger- General Ledger Detail**Banking:**- Bank Statements- Bank Reconciliations- Cash- Check Register**Accounts:**- Accounts Payable- Accounts Receivable- Confirmations**Asset Management:**- Fixed Assets- Fixed Asset Additions- Fixed Asset Disposals- Prepaid Expenses- Inventory**Liabilities:**- Debt Agreements- Accrued Expenses- Accrued Payroll- Lease Agreements- Leases**Investments:**- Investments- Investment Statements**Operational:**- Legal Invoices- Board Minutes- Revenue- Related Party Transactions- Personnel and Policy Manuals**Other:**- Other Information Not Previously Requested by CLAReturn a JSON object with a 'forms' array. Each form object should have:- page_start: starting page number (integer)- page_end: ending page number (integer)- form_type: one of the document types listed above (exact match)- confidence: confidence score between 0 and 1 (number)For forms that appear on non-contiguous pages, omit page_start/page_end and instead use:- page_ranges: array of objects with 'start' and 'end' propertiesExample response:{"forms": [{"page_start": 1, "page_end": 15, "form_type": "Trial Balance", "confidence": 0.96},{"page_start": 16, "page_end": 28, "form_type": "General Ledger Detail", "confidence": 0.94},{"form_type": "Other Information Not Previously Requested by CLA", "confidence": 0.55, "page_ranges": [{"start": 29, "end": 35}]}]}`,},{role: 'user',content: [{type: 'text',text: classificationPrompt,},...images.map((img) => ({type: 'image_url',image_url: {url: `data:image/${img.format};base64,${img.imageBuffer.toString('base64')}`,},})),],},];const requestBody = {model,messages,max_tokens: 4096,response_format: { type: 'json_object' },};try {const response = await this.httpClient.post(`${apiUrl}/chat/completions`,requestBody,{headers: {'Content-Type': 'application/json',Authorization: `Bearer ${apiKey}`,},timeout: 60000, // 60 seconds});const classification = JSON.parse(response.data.choices[0].message.content);this.logger.info('LLM classification success', {imageCount: images.length,});return classification;} catch (error) {this.logger.error('LLM invocation failed', { error });throw new Error(`LLM classification failed: ${error.message}`);}}}
ResultCollatorService
Responsibility: Merge and collate classification results from multiple batches
// src/features/classifications/v1/services/result-collator.service.tsimport { Logger } from 'framework-nodejs-extensions';import { BatchResult } from './batch-processor.service';export interface FormClassification {page_start?: number;page_end?: number;form_type: string;confidence: number;page_ranges?: Array<{ start: number; end: number }>;}export interface CollatedResult {is_multi_form: boolean;num_pages: number;forms: FormClassification[];classified_in_chunks: boolean;num_chunks: number;}export class ResultCollatorService {constructor(private logger: Logger) {}async collateResults(batchResults: BatchResult[]): Promise<CollatedResult> {this.logger.info('Collating batch results', {batchCount: batchResults.length,});const successfulBatches = batchResults.filter((b) => b.success);// Extract all classificationsconst classifications = successfulBatches.map((b) => b.classification);// Merge and normalize forms from all batchesconst allForms = this.mergeForms(classifications);// Determine if document contains multiple formsconst isMultiForm = allForms.length > 1;// Count total pagesconst totalPages = this.countTotalPages(batchResults);const collatedResult: CollatedResult = {is_multi_form: isMultiForm,num_pages: totalPages,forms: allForms,classified_in_chunks: true,num_chunks: batchResults.length,};this.logger.info('Result collation complete', {isMultiForm,totalPages,formsCount: allForms.length,numChunks: batchResults.length,});return collatedResult;}private mergeForms(classifications: any[]): FormClassification[] {const allForms: FormClassification[] = [];for (const classification of classifications) {if (classification.forms && Array.isArray(classification.forms)) {for (const form of classification.forms) {allForms.push({page_start: form.page_start,page_end: form.page_end,form_type: form.form_type,confidence: form.confidence,page_ranges: form.page_ranges,});}}}// Sort forms by page_start (or first range start)allForms.sort((a, b) => {const aStart = a.page_start || (a.page_ranges?.[0]?.start ?? Infinity);const bStart = b.page_start || (b.page_ranges?.[0]?.start ?? Infinity);return aStart - bStart;});// Optionally merge contiguous forms of the same typereturn this.mergeContiguousForms(allForms);}private mergeContiguousForms(forms: FormClassification[]): FormClassification[] {if (forms.length === 0) return [];const merged: FormClassification[] = [];let current = { ...forms[0] };for (let i = 1; i < forms.length; i++) {const next = forms[i];// Only merge if same form type and contiguous pagesif (current.form_type === next.form_type &¤t.page_end !== undefined &&next.page_start !== undefined &¤t.page_end + 1 === next.page_start) {// Merge into currentcurrent.page_end = next.page_end;// Average confidencecurrent.confidence = (current.confidence + next.confidence) / 2;} else {// Push current and start newmerged.push(current);current = { ...next };}}// Don't forget the last onemerged.push(current);return merged;}private countTotalPages(batchResults: BatchResult[]): number {const allPages = batchResults.flatMap((b) => b.pageNumbers);return Math.max(...allPages, 0);}}
3. Worker Component
ClassificationWorker
Responsibility: Background worker that processes classification jobs from queue
// src/features/classifications/v1/workers/classification-worker.tsimport { Logger } from 'framework-nodejs-extensions';import { ServiceBusClient } from 'framework-nodejs-pubsub';import { BlobStorageClient } from 'framework-nodejs-storage';import { JobRepository } from '../repositories/job.repository';import { ClassificationResultRepository } from '../repositories/classification-result.repository';import { PDFSplitterService } from '../services/pdf-splitter.service';import { ImageConverterService } from '../services/image-converter.service';import { BatchProcessorService } from '../services/batch-processor.service';import { ResultCollatorService } from '../services/result-collator.service';import { JobStatus } from '../models/job.model';export class ClassificationWorker {constructor(private logger: Logger,private serviceBusClient: ServiceBusClient,private blobStorageClient: BlobStorageClient,private jobRepository: JobRepository,private classificationResultRepository: ClassificationResultRepository,private pdfSplitterService: PDFSplitterService,private imageConverterService: ImageConverterService,private batchProcessorService: BatchProcessorService,private resultCollatorService: ResultCollatorService) {}async start(): Promise<void> {this.logger.info('Starting classification worker');// Subscribe to Service Bus queueawait this.serviceBusClient.subscribeToQueue('hqx-classification-jobs',async (message) => {await this.processJob(message);});this.logger.info('Worker subscribed to hqx-classification-jobs queue');}private async processJob(message: any): Promise<void> {const { jobId, pdfBlobPath, classificationPrompt } = message;this.logger.info('Processing classification job', { jobId });try {// 1. Download PDF from blob storageconst pdfBuffer = await this.blobStorageClient.download('hqx-classification-storage',pdfBlobPath);this.logger.info('PDF downloaded from storage', { jobId });// 2. Split PDF into pagesconst pages = await this.pdfSplitterService.splitPDF(pdfBuffer);// Update job with total pagesawait this.jobRepository.updateProgress(jobId, {totalPages: pages.length,processedPages: 0,});this.logger.info('PDF split into pages', { jobId, pageCount: pages.length });// 3. Convert pages to imagesconst images = await this.imageConverterService.convertPages(pages.map((p) => p.pdfBytes));this.logger.info('Pages converted to images', { jobId, imageCount: images.length });// 4. Process images in batchesconst batchResults = await this.batchProcessorService.processBatches(images,classificationPrompt);this.logger.info('Batch processing complete', {jobId,batchCount: batchResults.length,});// 5. Collate resultsconst collatedResult = await this.resultCollatorService.collateResults(batchResults);this.logger.info('Results collated', { jobId });// 6. Save result to databaseawait this.classificationResultRepository.create({jobId,classificationData: collatedResult,createdAt: new Date(),});// 7. Upload result to blob storageconst resultBlobPath = `classification-results/${jobId}/result.json`;await this.blobStorageClient.upload('hqx-classification-storage',resultBlobPath,Buffer.from(JSON.stringify(collatedResult, null, 2)));this.logger.info('Result saved to storage', { jobId, resultBlobPath });// 8. Update job status to successawait this.jobRepository.updateStatus(jobId, JobStatus.Success, {completedAt: new Date(),processedPages: collatedResult.processedPages,resultBlobPath,});this.logger.info('Classification job completed successfully', { jobId });} catch (error) {this.logger.error('Classification job failed', { jobId, error });// Update job status to failedawait this.jobRepository.updateStatus(jobId, JobStatus.Failed, {failedAt: new Date(),errorCode: 'PROCESSING_ERROR',errorMessage: error.message,errorDetails: error.stack,});}}async stop(): Promise<void> {this.logger.info('Stopping classification worker');await this.serviceBusClient.close();}}
Data Models
Job Model
// src/features/classifications/v1/models/job.model.tsexport enum JobStatus {InProgress = 'in-progress',Success = 'success',Failed = 'failed',}export interface Job {jobId: string;userId?: string;fileName: string;fileSizeBytes: number;pdfBlobPath: string;classificationPrompt: string;status: JobStatus;totalPages?: number;processedPages?: number;resultBlobPath?: string;errorCode?: string;errorMessage?: string;errorDetails?: string;createdAt: Date;updatedAt: Date;completedAt?: Date;failedAt?: Date;}
Classification Result Model
// src/features/classifications/v1/models/classification-result.model.tsexport interface ClassificationResult {id?: number;jobId: string;classificationData: any; // JSON blobcreatedAt: Date;}
DTOs
// src/features/classifications/v1/models/dto/classify-request.dto.tsexport interface ClassifyRequestDTO {fileBuffer: Buffer;fileName: string;classificationPrompt: string;userId?: string;}// src/features/classifications/v1/models/dto/classify-response.dto.tsexport interface ClassifyResponseDTO {jobId: string;status: string;createdAt: string;statusUrl: string;}// src/features/classifications/v1/models/dto/status-response.dto.tsexport interface StatusResponseDTO {jobId: string;status: string;createdAt: string;updatedAt: string;completedAt?: string;failedAt?: string;progress?: {totalPages: number;processedPages: number;percentComplete: number;};result?: any;error?: {code: string;message: string;details?: string;};}
Repository Layer
JobRepository
// src/features/classifications/v1/repositories/job.repository.tsimport { Logger } from 'framework-nodejs-extensions';import { DbConnector } from 'framework-nodejs-sql';import { Job, JobStatus } from '../models/job.model';export class JobRepository {constructor(private logger: Logger,private dbConnector: DbConnector) {}async create(job: Job): Promise<void> {const query = `INSERT INTO HQXClassificationJobs (jobId, userId, fileName, fileSizeBytes, pdfBlobPath,classificationPrompt, status, createdAt, updatedAt) VALUES (@jobId, @userId, @fileName, @fileSizeBytes, @pdfBlobPath,@classificationPrompt, @status, @createdAt, @updatedAt)`;await this.dbConnector.executeQuery(query, {jobId: job.jobId,userId: job.userId,fileName: job.fileName,fileSizeBytes: job.fileSizeBytes,pdfBlobPath: job.pdfBlobPath,classificationPrompt: job.classificationPrompt,status: job.status,createdAt: job.createdAt,updatedAt: job.updatedAt,});this.logger.info('Job created in database', { jobId: job.jobId });}async findById(jobId: string): Promise<Job | null> {const query = `SELECT * FROM HQXClassificationJobs WHERE jobId = @jobId`;const result = await this.dbConnector.executeQuery(query, { jobId });if (result.recordset.length === 0) {return null;}return this.mapToJob(result.recordset[0]);}async updateStatus(jobId: string,status: JobStatus,updates?: Partial<Job>): Promise<void> {const fields: string[] = ['status = @status', 'updatedAt = @updatedAt'];const params: any = {jobId,status,updatedAt: new Date(),};if (updates) {Object.keys(updates).forEach((key) => {fields.push(`${key} = @${key}`);params[key] = updates[key];});}const query = `UPDATE HQXClassificationJobsSET ${fields.join(', ')}WHERE jobId = @jobId`;await this.dbConnector.executeQuery(query, params);this.logger.info('Job status updated', { jobId, status });}async updateProgress(jobId: string,progress: { totalPages: number; processedPages: number }): Promise<void> {const query = `UPDATE HQXClassificationJobsSET totalPages = @totalPages,processedPages = @processedPages,updatedAt = @updatedAtWHERE jobId = @jobId`;await this.dbConnector.executeQuery(query, {jobId,totalPages: progress.totalPages,processedPages: progress.processedPages,updatedAt: new Date(),});this.logger.debug('Job progress updated', { jobId, progress });}private mapToJob(row: any): Job {return {jobId: row.jobId,userId: row.userId,fileName: row.fileName,fileSizeBytes: row.fileSizeBytes,pdfBlobPath: row.pdfBlobPath,classificationPrompt: row.classificationPrompt,status: row.status,totalPages: row.totalPages,processedPages: row.processedPages,resultBlobPath: row.resultBlobPath,errorCode: row.errorCode,errorMessage: row.errorMessage,errorDetails: row.errorDetails,createdAt: row.createdAt,updatedAt: row.updatedAt,completedAt: row.completedAt,failedAt: row.failedAt,};}}
ClassificationResultRepository
// src/features/classifications/v1/repositories/classification-result.repository.tsimport { Logger } from 'framework-nodejs-extensions';import { DbConnector } from 'framework-nodejs-sql';import { ClassificationResult } from '../models/classification-result.model';export class ClassificationResultRepository {constructor(private logger: Logger,private dbConnector: DbConnector) {}async create(result: ClassificationResult): Promise<void> {const query = `INSERT INTO HQXClassificationResults (jobId, classificationData, createdAt)VALUES (@jobId, @classificationData, @createdAt)`;await this.dbConnector.executeQuery(query, {jobId: result.jobId,classificationData: JSON.stringify(result.classificationData),createdAt: result.createdAt,});this.logger.info('Classification result saved', { jobId: result.jobId });}async findByJobId(jobId: string): Promise<ClassificationResult | null> {const query = `SELECT * FROM HQXClassificationResults WHERE jobId = @jobId`;const result = await this.dbConnector.executeQuery(query, { jobId });if (result.recordset.length === 0) {return null;}const row = result.recordset[0];return {id: row.id,jobId: row.jobId,classificationData: JSON.parse(row.classificationData),createdAt: row.createdAt,};}}
Configuration
Application Configuration
// src/shared/config/app-config.json{"FRAMEWORK_APPLICATION_NAME": "fluxx-hqx-classifier","FRAMEWORK_APPLICATION_VERSION": "1.0.0","FRAMEWORK_HTTP_PORT": 3000,"FRAMEWORK_LOGGING_LEVEL": "info","MAX_PDF_SIZE_MB": 50,"MAX_PDF_PAGES": 500,"IMAGE_BATCH_SIZE": 20,"IMAGE_FORMAT": "png","IMAGE_DPI": 150,"OPENAI_MODEL": "gpt-4-vision-preview","OPENAI_MAX_TOKENS": 4096,"OPENAI_TIMEOUT_MS": 60000,"BLOB_CONTAINER_NAME": "hqx-classification-storage","SERVICE_BUS_QUEUE_NAME": "hqx-classification-jobs","DB_POOL_MIN": 2,"DB_POOL_MAX": 10,"DB_IDLE_TIMEOUT_MS": 30000,"FEATURE_PARALLEL_BATCH_PROCESSING": false,"FEATURE_AUTO_RETRY_FAILED_BATCHES": true}
Dependency Injection Setup
// src/shared/config/dependency-config.tsimport { FrameworkFastify } from 'framework-nodejs-fastify';import { DependencyKeys, DependencyLifetime } from 'framework-nodejs-extensions';// Servicesimport { ClassificationService } from '../features/classifications/v1/services/classification.service';import { JobStatusService } from '../features/classifications/v1/services/job-status.service';import { PDFSplitterService } from '../features/classifications/v1/services/pdf-splitter.service';import { ImageConverterService } from '../features/classifications/v1/services/image-converter.service';import { BatchProcessorService } from '../features/classifications/v1/services/batch-processor.service';import { LLMInvocationService } from '../features/classifications/v1/services/llm-invocation.service';import { ResultCollatorService } from '../features/classifications/v1/services/result-collator.service';// Repositoriesimport { JobRepository } from '../features/classifications/v1/repositories/job.repository';import { ClassificationResultRepository } from '../features/classifications/v1/repositories/classification-result.repository';// Workerimport { ClassificationWorker } from '../features/classifications/v1/workers/classification-worker';export function registerDependencies(framework: FrameworkFastify): void {const container = framework.dependencies;// Repositories (Scoped - per request)container.registerClass('jobRepository',JobRepository,DependencyLifetime.Scoped);container.registerClass('classificationResultRepository',ClassificationResultRepository,DependencyLifetime.Scoped);// Services (Scoped - per request)container.registerClass('classificationService',ClassificationService,DependencyLifetime.Scoped);container.registerClass('jobStatusService',JobStatusService,DependencyLifetime.Scoped);// Processing Services (Transient - new instance each time)container.registerClass('pdfSplitterService',PDFSplitterService,DependencyLifetime.Transient);container.registerClass('imageConverterService',ImageConverterService,DependencyLifetime.Transient);container.registerClass('batchProcessorService',BatchProcessorService,DependencyLifetime.Transient);container.registerClass('llmInvocationService',LLMInvocationService,DependencyLifetime.Transient);container.registerClass('resultCollatorService',ResultCollatorService,DependencyLifetime.Transient);// Worker (Singleton - one instance)container.registerClass('classificationWorker',ClassificationWorker,DependencyLifetime.Singleton);}
Resilience Configuration
// src/shared/config/resilience-config.tsexport const resiliencePolicies = {llmApi: {retryOptions: {enabled: true,maxAttempts: 3,interval: 1000,intervalType: 'exponential',maxInterval: 10000,},circuitBreakerOptions: {enabled: true,errorThresholdPercentage: 50,resetTime: 60000,minimumThroughput: 3,},timeoutOptions: {enabled: true,timeoutMs: 60000,},},blobStorage: {retryOptions: {enabled: true,maxAttempts: 3,interval: 500,intervalType: 'exponential',},},database: {retryOptions: {enabled: true,maxAttempts: 3,interval: 100,intervalType: 'exponential',},circuitBreakerOptions: {enabled: true,errorThresholdPercentage: 20,resetTime: 30000,},},};
Error Handling
Custom Error Classes
// src/shared/utils/errors.tsexport class PDFProcessingError extends Error {constructor(message: string, public cause?: Error) {super(message);this.name = 'PDFProcessingError';}}export class ImageConversionError extends Error {constructor(message: string, public pageNumber?: number, public cause?: Error) {super(message);this.name = 'ImageConversionError';}}export class LLMInvocationError extends Error {constructor(message: string, public statusCode?: number, public cause?: Error) {super(message);this.name = 'LLMInvocationError';}}export class JobNotFoundError extends Error {constructor(public jobId: string) {super(`Job with ID ${jobId} not found`);this.name = 'JobNotFoundError';}}export class FileTooLargeError extends Error {constructor(public fileSizeMB: number, public maxSizeMB: number) {super(`File size ${fileSizeMB}MB exceeds maximum ${maxSizeMB}MB`);this.name = 'FileTooLargeError';}}export class InvalidFileTypeError extends Error {constructor(public mimeType: string) {super(`Invalid file type: ${mimeType}. Only PDF files are supported.`);this.name = 'InvalidFileTypeError';}}
Global Error Handler
// src/shared/middleware/error-handler.tsimport { FastifyInstance, FastifyError, FastifyRequest, FastifyReply } from 'fastify';import {PDFProcessingError,ImageConversionError,LLMInvocationError,JobNotFoundError,FileTooLargeError,InvalidFileTypeError,} from '../utils/errors';export async function errorHandlerPlugin(fastify: FastifyInstance) {fastify.setErrorHandler((error: FastifyError, request: FastifyRequest, reply: FastifyReply) => {request.log.error({ err: error }, 'Request error');// Custom error handlingif (error instanceof JobNotFoundError) {return reply.status(404).send({error: 'JOB_NOT_FOUND',message: error.message,timestamp: new Date().toISOString(),requestId: request.id,});}if (error instanceof FileTooLargeError) {return reply.status(413).send({error: 'FILE_TOO_LARGE',message: error.message,timestamp: new Date().toISOString(),requestId: request.id,});}if (error instanceof InvalidFileTypeError) {return reply.status(400).send({error: 'INVALID_FILE_TYPE',message: error.message,timestamp: new Date().toISOString(),requestId: request.id,});}if (error instanceof PDFProcessingError || error instanceof ImageConversionError) {return reply.status(422).send({error: 'PROCESSING_ERROR',message: error.message,timestamp: new Date().toISOString(),requestId: request.id,});}if (error instanceof LLMInvocationError) {return reply.status(502).send({error: 'LLM_SERVICE_ERROR',message: 'External classification service is unavailable',timestamp: new Date().toISOString(),requestId: request.id,});}// Standard Fastify errorsif (error.statusCode === 401) {return reply.status(401).send({error: 'UNAUTHORIZED',message: 'Authentication required',timestamp: new Date().toISOString(),requestId: request.id,});}if (error.statusCode === 403) {return reply.status(403).send({error: 'FORBIDDEN',message: 'Insufficient permissions',timestamp: new Date().toISOString(),requestId: request.id,});}if (error.statusCode && error.statusCode < 500) {return reply.status(error.statusCode).send({error: 'BAD_REQUEST',message: error.message,timestamp: new Date().toISOString(),requestId: request.id,});}// Internal server errorsreturn reply.status(500).send({error: 'INTERNAL_SERVER_ERROR',message: 'An unexpected error occurred',timestamp: new Date().toISOString(),requestId: request.id,});});}
Testing Strategy
Unit Tests
// tests/unit/services/result-collator.service.test.tsimport { describe, it, expect, vi, beforeEach } from 'vitest';import { ResultCollatorService } from '../../../src/features/classifications/v1/services/result-collator.service';import { Logger } from 'framework-nodejs-extensions';describe('ResultCollatorService', () => {let service: ResultCollatorService;let mockLogger: Logger;beforeEach(() => {mockLogger = {info: vi.fn(),error: vi.fn(),warn: vi.fn(),debug: vi.fn(),} as any;service = new ResultCollatorService(mockLogger);});it('should collate results from multiple batches', async () => {const batchResults = [{batchNumber: 1,pageNumbers: [1, 2, 3],success: true,classification: {forms: [{page_start: 1,page_end: 3,form_type: 'Trial Balance',confidence: 0.95,},],},},{batchNumber: 2,pageNumbers: [4, 5, 6],success: true,classification: {forms: [{page_start: 4,page_end: 6,form_type: 'Trial Balance',confidence: 0.93,},],},},];const result = await service.collateResults(batchResults);expect(result.is_multi_form).toBe(false);expect(result.num_pages).toBe(6);expect(result.forms).toHaveLength(1);expect(result.forms[0].form_type).toBe('Trial Balance');expect(result.forms[0].page_start).toBe(1);expect(result.forms[0].page_end).toBe(6);expect(result.classified_in_chunks).toBe(true);expect(result.num_chunks).toBe(2);});it('should handle failed batches', async () => {const batchResults = [{batchNumber: 1,pageNumbers: [1, 2, 3],success: true,classification: {forms: [{page_start: 1,page_end: 3,form_type: 'Trial Balance',confidence: 0.95,},],},},{batchNumber: 2,pageNumbers: [4, 5, 6],success: false,classification: null,error: 'LLM timeout',},];const result = await service.collateResults(batchResults);expect(result.num_pages).toBe(6);expect(result.forms.length).toBeGreaterThan(0);});});
Integration Tests
// tests/integration/routes/classifications.routes.test.tsimport { describe, it, expect, beforeAll, afterAll } from 'vitest';import { FrameworkFastify } from 'framework-nodejs-fastify';import fs from 'fs';import path from 'path';describe('POST /api/v1/Classifications', () => {let app: FrameworkFastify;let testPdfBuffer: Buffer;beforeAll(async () => {// Initialize test appapp = await FrameworkFastify.create({/* test config */});await app.start(0); // Random port// Load test PDFtestPdfBuffer = fs.readFileSync(path.join(__dirname, '../../fixtures/sample.pdf'));});afterAll(async () => {await app.stop();});it('should create classification job for valid PDF', async () => {const response = await app.server.inject({method: 'POST',url: '/api/v1/Classifications',payload: {file: testPdfBuffer,classificationPrompt: 'Classify this document',},headers: {'content-type': 'multipart/form-data',authorization: 'Bearer test-token',},});expect(response.statusCode).toBe(201);const body = JSON.parse(response.body);expect(body.jobId).toBeDefined();expect(body.status).toBe('in-progress');expect(body.statusUrl).toContain(`/api/v1/Classifications/${body.jobId}`);expect(response.headers.location).toContain('/api/v1/Classifications/');});it('should return 400 for missing file', async () => {const response = await app.server.inject({method: 'POST',url: '/api/v1/Classifications',payload: {classificationPrompt: 'Classify this document',},headers: {authorization: 'Bearer test-token',},});expect(response.statusCode).toBe(400);const body = JSON.parse(response.body);expect(body.error).toBe('MISSING_FILE');});it('should return 400 for non-PDF file', async () => {const response = await app.server.inject({method: 'POST',url: '/api/v1/Classifications',payload: {file: Buffer.from('not a pdf'),classificationPrompt: 'Classify this document',},headers: {'content-type': 'multipart/form-data',authorization: 'Bearer test-token',},});expect(response.statusCode).toBe(400);const body = JSON.parse(response.body);expect(body.error).toBe('INVALID_FILE_TYPE');});it('should return 401 for missing authentication', async () => {const response = await app.server.inject({method: 'POST',url: '/api/v1/Classifications',payload: {file: testPdfBuffer,classificationPrompt: 'Classify this document',},headers: {'content-type': 'multipart/form-data',},});expect(response.statusCode).toBe(401);});});describe('GET /api/v1/Classifications/:jobId', () => {let app: FrameworkFastify;beforeAll(async () => {app = await FrameworkFastify.create({/* test config */});await app.start(0);});afterAll(async () => {await app.stop();});it('should return correct result format on success', async () => {const jobId = '550e8400-e29b-41d4-a716-446655440000';const response = await app.server.inject({method: 'GET',url: `/api/v1/Classifications/${jobId}`,headers: {authorization: 'Bearer test-token',},});expect(response.statusCode).toBe(200);const body = JSON.parse(response.body);expect(body.jobId).toBe(jobId);expect(body.status).toBe('success');expect(body.result).toBeDefined();expect(body.result.is_multi_form).toBeDefined();expect(body.result.num_pages).toBeGreaterThan(0);expect(body.result.forms).toBeInstanceOf(Array);expect(body.result.classified_in_chunks).toBe(true);expect(body.result.num_chunks).toBeGreaterThan(0);// Validate form structureif (body.result.forms.length > 0) {const firstForm = body.result.forms[0];expect(firstForm.form_type).toBeDefined();expect(firstForm.confidence).toBeGreaterThan(0);// Form should have either page_start/page_end OR page_rangesconst hasPageRange = firstForm.page_start !== undefined && firstForm.page_end !== undefined;const hasPageRanges = firstForm.page_ranges !== undefined && Array.isArray(firstForm.page_ranges);expect(hasPageRange || hasPageRanges).toBe(true);}});it('should return 404 for non-existent job', async () => {const response = await app.server.inject({method: 'GET',url: '/api/v1/Classifications/00000000-0000-0000-0000-000000000000',headers: {authorization: 'Bearer test-token',},});expect(response.statusCode).toBe(404);const body = JSON.parse(response.body);expect(body.error).toBe('JOB_NOT_FOUND');});});
Test Coverage Requirements
| Component | Coverage Target |
|---|---|
| Services | ≥ 90% |
| Repositories | ≥ 85% |
| Routes | ≥ 80% |
| Utilities | ≥ 95% |
| Overall | ≥ 85% |
Sequence Diagrams
Classification Job Creation Flow
Client API Server Blob Storage Service Bus Database| | | | ||-- POST /api/v1/Classifications ->| | | || | | | || |-- Validate --------| | || | | | || |-- Upload PDF ----->| | || |<-- Success --------| | || | | | || |-- Create Job ------|--------------------|------------------->|| | | |<-- Job Created --|| | | | || |-- Send Message ----|--------------------|------------------->|| |<-- Message Sent ---| | || | | | ||<-- 201 Created --------| | | || (jobId, statusUrl) | | | |
Background Processing Flow
Service Bus Worker PDF Splitter Image Converter LLM API Result Collator Database Blob Storage| | | | | | | ||-- Message ---->| | | | | | || | | | | | | || |-- Download PDF ----|------------------|-------------------------------------------------|------------------>|| |<-- PDF ------------|------------------|-------------------------------------------------|-------------------|| | | | | | | || |-- Split PDF ------>| | | | | || |<-- Pages ----------| | | | | || | | | | | | || |-- Convert to Images|----------------->| | | | || |<-- Images ---------|------------------| | | | || | | | | | | || |-- Batch 1 (20 imgs)|------------------|--------------->| | | || |<-- Classification --|------------------|----------------| | | || | | | | | | || |-- Batch 2 (20 imgs)|------------------|--------------->| | | || |<-- Classification --|------------------|----------------| | | || | | | | | | || |-- Collate Results --|------------------|--------------------------------------------------->| || |<-- Final Result ---|------------------|----------------------------------------------------| || | | | | | | || |-- Save Result ------|------------------|-------------------------------------------------|------------------>|| |<-- Saved -----------|------------------|-------------------------------------------------|-------------------|| | | | | | | || |-- Update Job Status|------------------|-------------------------------------------------|------------------>|| |<-- Updated ---------|------------------|-------------------------------------------------|-------------------|
Status Polling Flow
Client API Server Database Blob Storage| | | ||-- GET /api/v1/Classifications/{jobId} -->| | || | | || |-- Query Job ---->| || |<-- Job Data -----| || | | || |-- Get Result ----|----------------->|| |<-- Result -------|------------------|| | | ||<-- 200 OK -----------| | || (status + result) | | |
Database Schema
HQXClassificationJobs Table
CREATE TABLE HQXClassificationJobs (id INT IDENTITY(1,1) PRIMARY KEY,jobId NVARCHAR(50) NOT NULL UNIQUE,userId NVARCHAR(100),fileName NVARCHAR(255) NOT NULL,fileSizeBytes BIGINT NOT NULL,pdfBlobPath NVARCHAR(500) NOT NULL,classificationPrompt NVARCHAR(MAX) NOT NULL,status NVARCHAR(20) NOT NULL, -- 'in-progress', 'success', 'failed'totalPages INT,processedPages INT,resultBlobPath NVARCHAR(500),errorCode NVARCHAR(50),errorMessage NVARCHAR(1000),errorDetails NVARCHAR(MAX),createdAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),updatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),completedAt DATETIME2,failedAt DATETIME2,INDEX IX_HQXClassificationJobs_JobId (jobId),INDEX IX_HQXClassificationJobs_Status (status),INDEX IX_HQXClassificationJobs_CreatedAt (createdAt));
HQXClassificationResults Table
CREATE TABLE HQXClassificationResults (id INT IDENTITY(1,1) PRIMARY KEY,jobId NVARCHAR(50) NOT NULL,classificationData NVARCHAR(MAX) NOT NULL, -- JSON blobcreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),FOREIGN KEY (jobId) REFERENCES HQXClassificationJobs(jobId),INDEX IX_HQXClassificationResults_JobId (jobId));
Application Entry Point
// src/index.tsimport 'dotenv/config';import { FrameworkFastify } from 'framework-nodejs-fastify';import { LogLevelEnum, DependencyKeys } from 'framework-nodejs-extensions';import { AppInsightsLogConfigProfile } from 'framework-nodejs-logging';import { DefaultAzureCredential } from '@azure/identity';import { registerDependencies } from './shared/config/dependency-config';import { classificationsRoutes } from './features/classifications/v1/routes/classifications.routes';import { errorHandlerPlugin } from './shared/middleware/error-handler';import { ClassificationWorker } from './features/classifications/v1/workers/classification-worker';async function main() {// Create framework instanceconst framework = await FrameworkFastify.create({healthCheck: {enabled: true,path: '/health',},telemetryOptions: {serviceName: 'fluxx-hqx-classifier',serviceNamespace: 'production',enableLiveMetrics: true,},loggerOptions: {level: LogLevelEnum.info,config: new AppInsightsLogConfigProfile(process.env.APPLICATIONINSIGHTS_CONNECTION_STRING!,new DefaultAzureCredential()),},serverOptions: {caseSensitive: false,ignoreTrailingSlash: true,bodyLimit: 52428800, // 50 MB},});// Initialize configurationawait framework.initAppConfig({useEnvironmentVariables: true,azureAppConfigOptions: {useManagedIdentity: true,endpoint: process.env.FRAMEWORK_AZURE_APP_CONFIG_ENDPOINT,},fileConfigPath: './shared/config/app-config.json',});// Enable middlewareawait framework.enableMiddleware();// Enable CORSframework.enableCORS({origins: [process.env.ALLOWED_ORIGIN_1,process.env.ALLOWED_ORIGIN_2,].filter(Boolean),methods: ['GET', 'POST'],allowedHeaders: ['Content-Type', 'Authorization'],});// Enable authenticationframework.enableAuthentication();// Enable CSRF protectionframework.enableCSRFProtection();// Initialize Swaggerframework.initializeSwagger({specificationPath: './docs/openapi.yaml',swaggerUIConfig: {routePrefix: '/api-docs',},});// Register dependenciesregisterDependencies(framework);// Register error handlerawait framework.server.register(errorHandlerPlugin);// Register routesconst classificationService = framework.dependencies.resolve('classificationService');const jobStatusService = framework.dependencies.resolve('jobStatusService');const logger = framework.dependencies.resolve(DependencyKeys.Logger);await framework.server.register(classificationsRoutes, {classificationService,jobStatusService,logger,});// Start background workerconst worker = framework.dependencies.resolve<ClassificationWorker>('classificationWorker');await worker.start();// Graceful shutdownframework.onShutdown.on('onShutdown', async () => {logger.info('Stopping classification worker');await worker.stop();});// Start serverconst port = process.env.FRAMEWORK_HTTP_PORT || 3000;await framework.start(Number(port));logger.info(`fluxx-hqx-classifier started on port ${port}`);}main().catch((error) => {console.error('Failed to start application:', error);process.exit(1);});
Deployment
Dockerfile
# Build stageFROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build# Production stageFROM node:20-alpine# Install Ghostscript (required for pdf2pic)RUN apk add --no-cache ghostscript graphicsmagickWORKDIR /appCOPY package*.json ./RUN npm ci --productionCOPY --from=builder /app/dist ./distCOPY --from=builder /app/docs ./docsENV NODE_ENV=productionEXPOSE 3000USER nodeCMD ["node", "dist/index.js"]
Kubernetes Deployment
apiVersion: apps/v1kind: Deploymentmetadata:name: fluxx-hqx-classifiernamespace: productionspec:replicas: 3selector:matchLabels:app: fluxx-hqx-classifiertemplate:metadata:labels:app: fluxx-hqx-classifierspec:containers:- name: apiimage: myregistry.azurecr.io/fluxx-hqx-classifier:1.0.0ports:- containerPort: 3000env:- name: NODE_ENVvalue: "production"- name: FRAMEWORK_HTTP_PORTvalue: "3000"- name: FRAMEWORK_AZURE_APP_CONFIG_ENDPOINTvalueFrom:configMapKeyRef:name: app-configkey: azure-app-config-endpoint- name: APPLICATIONINSIGHTS_CONNECTION_STRINGvalueFrom:secretKeyRef:name: app-secretskey: app-insights-connection-string- name: OPENAI_API_KEYvalueFrom:secretKeyRef:name: app-secretskey: openai-api-keyresources:requests:memory: "1Gi"cpu: "500m"limits:memory: "2Gi"cpu: "2000m"livenessProbe:httpGet:path: /healthport: 3000initialDelaySeconds: 30periodSeconds: 10readinessProbe:httpGet:path: /healthport: 3000initialDelaySeconds: 10periodSeconds: 5---apiVersion: v1kind: Servicemetadata:name: fluxx-hqx-classifiernamespace: productionspec:selector:app: fluxx-hqx-classifierports:- protocol: TCPport: 80targetPort: 3000type: ClusterIP
Document Owner: Engineering Team
Reviewers: Tech Lead, Senior Engineers
Implementation Start Date: TBD
Target Completion Date: TBD