Skip to navigation

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 Definition
interface 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.ts
import { 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 support
  await fastify.register(require('@fastify/multipart'), {
    limits: {
      fileSize: 50 * 1024 * 1024, // 50MB max file size
      files: 10, // Maximum 10 files per request
    },
  });
  fastify.route({
    method: 'POST',
    url: '/invoice_document/upload/:client_id',
    preHandler: [
      authGuard, // JWT validation
      roleGuard(['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.ts
import { 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 attempt
      await this.auditService.logUploadAttempt(client_id, userInfo);
      // Check if multiple files or single file
      const filesIterator = request.files();
      const first = await filesIterator.next();
      if (!first.done) {
        // Multiple files upload
        return await this.handleMultipleFiles(client_id, first, filesIterator, userInfo);
      } else {
        // Single file upload
        return 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 file
    async function* allFiles() {
      yield first.value;
      for await (const file of filesIterator) {
        yield file;
      }
    }
    // Validate all files
    for 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 API
    const fluxxResponse = await this.fluxxService.uploadMultipleFiles(
      client_id,
      validatedFiles,
      userInfo
    );
    // Log successful upload
    await 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 file
    const 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 API
    const fluxxResponse = await this.fluxxService.uploadSingleFile(
      client_id,
      buffer,
      filename,
      contentType,
      userInfo
    );
    // Log successful upload
    await 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.ts
import 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 uploads
      headers: {
        '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 data
    for (const file of files) {
      const buffer = await file.toBuffer();
      formData.append('files', buffer, {
        filename: file.filename,
        contentType: file.mimetype,
      });
    }
    // Add metadata
    formData.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 metadata
    formData.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.ts
export 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; // 50MB
  async validateInvoiceFile(file: any): Promise<FileValidationResult> {
    const errors: string[] = [];
    const warnings: string[] = [];
    // Check file size
    if (file.file.bytesRead > this.maxFileSize) {
      errors.push(`File size exceeds maximum limit of ${this.maxFileSize / 1024 / 1024}MB`);
    }
    // Check MIME type
    if (!this.allowedMimeTypes.includes(file.mimetype)) {
      errors.push(`File type ${file.mimetype} is not allowed for invoice uploads`);
    }
    // Check filename
    if (!file.filename || file.filename.length === 0) {
      errors.push('Filename is required');
    }
    // Check for suspicious file extensions
    const 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 files
    if (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 validation
    const pdfHeader = buffer.slice(0, 4).toString();
    return pdfHeader === '%PDF';
  }
}

#### 3.2 Authentication Middleware

// middleware/authGuard.ts
import { 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 request
    request.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.ts
import { 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.ts
export 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.ts
import { 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.ts
import { 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

# .env
FLUXX_API_URL=[https://api.fluxx.example.com](https://api.fluxx.example.com "https://api.fluxx.example.com/")
FLUXX_API_BASE_PATH=/api/v1
FLUXX_API_TOKEN=your-fluxx-api-token
JWT_SECRET=your-jwt-secret
NODE_ENV=development
# File upload limits
MAX_FILE_SIZE=52428800  # 50MB in bytes
MAX_FILES_PER_REQUEST=10
# Logging
LOG_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.0
info:
  title: Invoice Document Upload API
  version: 1.0.0
paths:
  /invoice_document/upload/{client_id}:
    post:
      summary: Upload invoice documents
      security:
        - bearerAuth: []
      parameters:
        - name: client_id
          in: path
          required: true
          schema:
            type: string
            pattern: '^[a-zA-Z0-9-_]+$'
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                files:
                  type: array
                  items:
                    type: string
                    format: binary
                metadata:
                  type: object
                  properties:
                    invoiceNumber:
                      type: string
                    vendorId:
                      type: string
      responses:
        '200':
          description: Upload successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  uploadId:
                    type: string
                  client_id:
                    type: string
                  files:
                    type: array
                    items:
                      type: object