Invoice Document Upload API
# Design Story: Invoice Document Upload API
## Overview
Implement a POST /invoice_document/upload endpoint in Node.js using Fastify that accepts invoice document uploads and forwards them to an external Fluxx API for processing.
## User Story
As a user of the web-documents-ai application
I want to upload invoice documents through a secure API endpoint
So that the documents can be processed by the external Fluxx system for further analysis and workflow management
## Technical Requirements
### API Specification
-
Method: POST
-
Endpoint:
/invoice_document/upload/:client_id -
Framework: Node.js with Fastify
-
Content-Type:
multipart/form-data -
Authentication: JWT token validation required
-
Authorization: Role-based access control
### External API Integration
-
Target: Fluxx API upload endpoint
-
Pattern:
POST /{basePath}/upload/:client_id -
Features:
- Multi-file upload support
- Single file fallback
- File validation and processing
## Architecture Design
### 1. API Layer Structure
// Route Definitioninterface UploadRouteParams {account_id: string;}interface UploadRequestBody {files: MultipartFile[];metadata?: {invoiceNumber?: string;vendorId?: string;clientId?: string;description?: string;};}interface UploadResponse {success: boolean;uploadId: string;clientId: string;files: UploadedFileInfo[];fluxxResponse?: any;errors?: string[];}
### 2. Component Architecture
┌─────────────────────────────────────────────────────────────┐│ Client Application ││ (web-documents-ai) │└──────────────────────┬──────────────────────────────────────┘│ POST /invoice_document/upload/:appId│ multipart/form-data▼┌─────────────────────────────────────────────────────────────┐│ Fastify Server ││ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ ││ │ Auth Guard │ │ Upload Route │ │ Validation │ ││ │ Middleware │ │ Handler │ │ Middleware │ ││ └─────────────────┘ └─────────────────┘ └────────────────┘ ││ │ │ │ ││ ▼ ▼ ▼ ││ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ ││ │ Upload Service │ │ Fluxx Service │ │ File Service │ ││ │ Controller │ │ Integration │ │ Utilities │ ││ └─────────────────┘ └─────────────────┘ └────────────────┘ │└──────────────────────┬──────────────────────────────────────┘│ POST /{basePath}/upload/:appId│ multipart/form-data▼┌─────────────────────────────────────────────────────────────┐│ External Fluxx API ││ Upload Controller │└─────────────────────────────────────────────────────────────┘
## Implementation Plan
### Phase 1: Core Infrastructure
#### 1.1 Route Registration
// routes/invoiceUpload.tsimport { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';import { InvoiceUploadController } from '../controllers/invoiceUploadController';import { authGuard } from '../middleware/authGuard';import { roleGuard } from '../middleware/roleGuard';export async function invoiceUploadRoutes(fastify: FastifyInstance) {// Register file upload supportawait fastify.register(require('@fastify/multipart'), {limits: {fileSize: 50 * 1024 * 1024, // 50MB max file sizefiles: 10, // Maximum 10 files per request},});fastify.route({method: 'POST',url: '/invoice_document/upload/:client_id',preHandler: [authGuard, // JWT validationroleGuard(['admin', 'manager', 'user']), // Role-based access],schema: {params: {type: 'object',properties: {client_id: { type: 'string', pattern: '^[a-zA-Z0-9-_]+$' }},required: ['client_id']},response: {200: {type: 'object',properties: {success: { type: 'boolean' },uploadId: { type: 'string' },client_id: { type: 'string' },files: {type: 'array',items: {type: 'object',properties: {filename: { type: 'string' },size: { type: 'number' },contentType: { type: 'string' },uploadedAt: { type: 'string' }}}}}},400: {type: 'object',properties: {error: { type: 'string' },details: { type: 'array', items: { type: 'string' } }}}}},handler: async (request: FastifyRequest, reply: FastifyReply) => {const controller = new InvoiceUploadController();return controller.handleUpload(request, reply);},});}
#### 1.2 Upload Controller
// controllers/invoiceUploadController.tsimport { FastifyRequest, FastifyReply } from 'fastify';import { FluxxService } from '../services/fluxxService';import { FileValidationService } from '../services/fileValidationService';import { AuditService } from '../services/auditService';export class InvoiceUploadController {private fluxxService: FluxxService;private fileValidator: FileValidationService;private auditService: AuditService;constructor() {this.fluxxService = new FluxxService();this.fileValidator = new FileValidationService();this.auditService = new AuditService();}async handleUpload(request: FastifyRequest, reply: FastifyReply) {try {const { client_id } = request.params as { client_id: string };const userInfo = request.user; // From JWT middleware// Log upload attemptawait this.auditService.logUploadAttempt(client_id, userInfo);// Check if multiple files or single fileconst filesIterator = request.files();const first = await filesIterator.next();if (!first.done) {// Multiple files uploadreturn await this.handleMultipleFiles(client_id, first, filesIterator, userInfo);} else {// Single file uploadreturn await this.handleSingleFile(request, client_id, userInfo);}} catch (error) {request.log.error('Upload error:', error);return reply.status(500).send({success: false,error: 'Internal server error during upload',details: process.env.NODE_ENV === 'development' ? [error.message] : []});}}private async handleMultipleFiles(client_id: string,first: any,filesIterator: any,userInfo: any) {const validatedFiles = [];// Recreate file iterator including first fileasync function* allFiles() {yield first.value;for await (const file of filesIterator) {yield file;}}// Validate all filesfor await (const file of allFiles()) {const validation = await this.fileValidator.validateInvoiceFile(file);if (!validation.isValid) {throw new Error(`Invalid file ${file.filename}: ${validation.errors.join(', ')}`);}validatedFiles.push(file);}// Forward to Fluxx APIconst fluxxResponse = await this.fluxxService.uploadMultipleFiles(client_id,validatedFiles,userInfo);// Log successful uploadawait this.auditService.logUploadSuccess(client_id, validatedFiles, userInfo);return {success: true,uploadId: fluxxResponse.uploadId,client_id,files: validatedFiles.map(f => ({filename: f.filename,size: f.file.bytesRead,contentType: f.mimetype,uploadedAt: new Date().toISOString()})),fluxxResponse: fluxxResponse.data};}private async handleSingleFile(request: FastifyRequest, client_id: string, userInfo: any) {const data = await request.file();if (!data) {throw new Error('No file provided');}// Validate fileconst validation = await this.fileValidator.validateInvoiceFile(data);if (!validation.isValid) {throw new Error(`Invalid file: ${validation.errors.join(', ')}`);}const buffer = await data.toBuffer();const filename = data.filename ?? '';const contentType = data.mimetype ?? 'application/octet-stream';// Forward to Fluxx APIconst fluxxResponse = await this.fluxxService.uploadSingleFile(client_id,buffer,filename,contentType,userInfo);// Log successful uploadawait this.auditService.logUploadSuccess(client_id, [data], userInfo);return {success: true,uploadId: fluxxResponse.uploadId,client_id,files: [{filename,size: buffer.length,contentType,uploadedAt: new Date().toISOString()}],fluxxResponse: fluxxResponse.data};}}
### Phase 2: External API Integration
#### 2.1 Fluxx Service
// services/fluxxService.tsimport axios, { AxiosInstance } from 'axios';import FormData from 'form-data';export class FluxxService {private client: AxiosInstance;private basePath: string;constructor() {this.basePath = process.env.FLUXX_API_BASE_PATH || '/api/v1';this.client = axios.create({baseURL: process.env.FLUXX_API_URL,timeout: 300000, // 5 minutes for file uploadsheaders: {'Authorization': `Bearer ${process.env.FLUXX_API_TOKEN}`,},});}async uploadMultipleFiles(client_id: string,files: any[],userInfo: any): Promise<any> {const formData = new FormData();// Add files to form datafor (const file of files) {const buffer = await file.toBuffer();formData.append('files', buffer, {filename: file.filename,contentType: file.mimetype,});}// Add metadataformData.append('metadata', JSON.stringify({uploadedBy: userInfo.email,clientId: userInfo.selectedClient,timestamp: new Date().toISOString(),}));try {const response = await this.client.post(`${this.basePath}/upload/${client_id}`,formData,{headers: {...formData.getHeaders(),'X-Uploaded-By': userInfo.email,'X-Client-Id': userInfo.selectedClient,},});return {uploadId: response.data.uploadId || `upload-${Date.now()}`,data: response.data,status: response.status,};} catch (error) {throw new Error(`Fluxx API error: ${error.response?.data?.message || error.message}`);}}async uploadSingleFile(client_id: string,buffer: Buffer,filename: string,contentType: string,userInfo: any): Promise<any> {const formData = new FormData();formData.append('file', buffer, {filename,contentType,});// Add metadataformData.append('metadata', JSON.stringify({uploadedBy: userInfo.email,clientId: userInfo.selectedClient,timestamp: new Date().toISOString(),}));try {const response = await this.client.post(`${this.basePath}/upload/${client_id}`,formData,{headers: {...formData.getHeaders(),'X-Uploaded-By': userInfo.email,'X-Client-Id': userInfo.selectedClient,},});return {uploadId: response.data.uploadId || `upload-${Date.now()}`,data: response.data,status: response.status,};} catch (error) {throw new Error(`Fluxx API error: ${error.response?.data?.message || error.message}`);}}}
### Phase 3: Validation and Security
#### 3.1 File Validation Service
// services/fileValidationService.tsexport interface FileValidationResult {isValid: boolean;errors: string[];warnings: string[];}export class FileValidationService {private allowedMimeTypes = ['application/pdf','image/jpeg','image/png','image/tiff','application/vnd.ms-excel','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',];private maxFileSize = 50 * 1024 * 1024; // 50MBasync validateInvoiceFile(file: any): Promise<FileValidationResult> {const errors: string[] = [];const warnings: string[] = [];// Check file sizeif (file.file.bytesRead > this.maxFileSize) {errors.push(`File size exceeds maximum limit of ${this.maxFileSize / 1024 / 1024}MB`);}// Check MIME typeif (!this.allowedMimeTypes.includes(file.mimetype)) {errors.push(`File type ${file.mimetype} is not allowed for invoice uploads`);}// Check filenameif (!file.filename || file.filename.length === 0) {errors.push('Filename is required');}// Check for suspicious file extensionsconst suspiciousExtensions = ['.exe', '.bat', '.cmd', '.com', '.scr', '.pif'];const extension = file.filename?.toLowerCase().split('.').pop();if (extension && suspiciousExtensions.includes(`.${extension}`)) {errors.push(`File extension .${extension} is not allowed`);}// Additional validation for PDF filesif (file.mimetype === 'application/pdf') {try {const buffer = await file.toBuffer();if (!this.isPDFValid(buffer)) {errors.push('PDF file appears to be corrupted or invalid');}} catch (error) {warnings.push('Could not validate PDF file integrity');}}return {isValid: errors.length === 0,errors,warnings,};}private isPDFValid(buffer: Buffer): boolean {// Basic PDF header validationconst pdfHeader = buffer.slice(0, 4).toString();return pdfHeader === '%PDF';}}
#### 3.2 Authentication Middleware
// middleware/authGuard.tsimport { FastifyRequest, FastifyReply } from 'fastify';import jwt from 'jsonwebtoken';export async function authGuard(request: FastifyRequest, reply: FastifyReply) {try {const authHeader = request.headers.authorization;if (!authHeader || !authHeader.startsWith('Bearer ')) {return reply.status(401).send({error: 'Authentication required',details: ['Missing or invalid authorization header']});}const token = authHeader.substring(7);const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;// Attach user info to requestrequest.user = {id: decoded.sub,email: decoded.email,roles: decoded.roles || [],selectedClient: decoded.selectedClient,};} catch (error) {return reply.status(401).send({error: 'Invalid token',details: ['Token verification failed']});}}
#### 3.3 Role-Based Access Control
// middleware/roleGuard.tsimport { FastifyRequest, FastifyReply } from 'fastify';export function roleGuard(allowedRoles: string[]) {return async (request: FastifyRequest, reply: FastifyReply) => {const user = request.user as any;if (!user) {return reply.status(401).send({error: 'Authentication required',details: ['User not authenticated']});}const hasRequiredRole = allowedRoles.some(role =>user.roles.includes(role) || user.roles.includes('admin'));if (!hasRequiredRole) {return reply.status(403).send({error: 'Insufficient permissions',details: [`Required roles: ${allowedRoles.join(', ')}`]});}};}
### Phase 4: Monitoring and Logging
#### 4.1 Audit Service
// services/auditService.tsexport class AuditService {async logUploadAttempt(client_id: string, userInfo: any): Promise<void> {console.log(`Upload attempt - Application: ${client_id}, User: ${userInfo.email}`);// In production, log to database or monitoring service// await this.database.audit.create({// action: 'UPLOAD_ATTEMPT',// client_id,// userId: userInfo.id,// userEmail: userInfo.email,// clientId: userInfo.selectedClient,// timestamp: new Date(),// });}async logUploadSuccess(client_id: string, files: any[], userInfo: any): Promise<void> {console.log(`Upload success - Application: ${client_id}, Files: ${files.length}, User: ${userInfo.email}`);// In production, log to database or monitoring service// await this.database.audit.create({// action: 'UPLOAD_SUCCESS',// client_id,// userId: userInfo.id,// userEmail: userInfo.email,// clientId: userInfo.selectedClient,// fileCount: files.length,// filenames: files.map(f => f.filename),// timestamp: new Date(),// });}}
## Testing Strategy
### Unit Tests
// tests/controllers/invoiceUploadController.test.tsimport { InvoiceUploadController } from '../../src/controllers/invoiceUploadController';import { createMockRequest, createMockReply } from '../helpers/mockFastify';describe('InvoiceUploadController', () => {let controller: InvoiceUploadController;beforeEach(() => {controller = new InvoiceUploadController();});describe('handleUpload', () => {it('should handle single file upload successfully', async () => {const mockRequest = createMockRequest({params: { client_id: 'test-app-123' },user: { email: 'test@example.com', roles: ['user'] },file: () => Promise.resolve({filename: 'invoice.pdf',mimetype: 'application/pdf',toBuffer: () => Buffer.from('mock pdf content'),}),});const mockReply = createMockReply();const result = await controller.handleUpload(mockRequest, mockReply);expect(result.success).toBe(true);expect(result.client_id).toBe('test-app-123');expect(result.files).toHaveLength(1);});it('should handle validation errors', async () => {const mockRequest = createMockRequest({params: { client_id: 'test-app-123' },user: { email: 'test@example.com', roles: ['user'] },file: () => Promise.resolve({filename: 'malicious.exe',mimetype: 'application/x-executable',toBuffer: () => Buffer.from('malicious content'),}),});const mockReply = createMockReply();await expect(controller.handleUpload(mockRequest, mockReply)).rejects.toThrow('File type application/x-executable is not allowed');});});});
### Integration Tests
// tests/integration/invoiceUpload.test.tsimport { build } from '../helpers/app';import FormData from 'form-data';describe('Invoice Upload API', () => {const app = build();afterAll(async () => {await app.close();});it('should upload invoice document successfully', async () => {const form = new FormData();form.append('file', Buffer.from('mock pdf'), {filename: 'test-invoice.pdf',contentType: 'application/pdf',});const response = await app.inject({method: 'POST',url: '/invoice_document/upload/test-app-123',headers: {authorization: 'Bearer valid-jwt-token',...form.getHeaders(),},payload: form,});expect(response.statusCode).toBe(200);const result = JSON.parse(response.payload);expect(result.success).toBe(true);expect(result.client_id).toBe('test-app-123');});it('should reject unauthorized requests', async () => {const response = await app.inject({method: 'POST',url: '/invoice_document/upload/test-app-123',payload: {},});expect(response.statusCode).toBe(401);});});
## Environment Configuration
### Required Environment Variables
# .envFLUXX_API_URL=[https://api.fluxx.example.com](https://api.fluxx.example.com "https://api.fluxx.example.com/")FLUXX_API_BASE_PATH=/api/v1FLUXX_API_TOKEN=your-fluxx-api-tokenJWT_SECRET=your-jwt-secretNODE_ENV=development# File upload limitsMAX_FILE_SIZE=52428800 # 50MB in bytesMAX_FILES_PER_REQUEST=10# LoggingLOG_LEVEL=info
## Deployment Considerations
### Production Checklist
-
Configure proper file size limits
-
Set up monitoring and alerting
-
Implement rate limiting
-
Configure CORS policies
-
Set up SSL/TLS termination
-
Configure backup and disaster recovery
-
Implement database auditing
-
Set up log aggregation
### Monitoring
-
API response times
-
File upload success/failure rates
-
External API (Fluxx) availability
-
File validation error rates
-
Authentication/authorization failures
## API Documentation
### OpenAPI Schema
openapi: 3.0.0info:title: Invoice Document Upload APIversion: 1.0.0paths:/invoice_document/upload/{client_id}:post:summary: Upload invoice documentssecurity:- bearerAuth: []parameters:- name: client_idin: pathrequired: trueschema:type: stringpattern: '^[a-zA-Z0-9-_]+$'requestBody:content:multipart/form-data:schema:type: objectproperties:files:type: arrayitems:type: stringformat: binarymetadata:type: objectproperties:invoiceNumber:type: stringvendorId:type: stringresponses:'200':description: Upload successfulcontent:application/json:schema:type: objectproperties:success:type: booleanuploadId:type: stringclient_id:type: stringfiles:type: arrayitems:type: object