Claude Code Plugins

Community-maintained marketplace

Feedback

Complete guide for implementing file uploads in IntelliFill with React-dropzone frontend, Multer backend, file validation, Bull queue processing, and security best practices

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name file-upload
description Complete guide for implementing file uploads in IntelliFill with React-dropzone frontend, Multer backend, file validation, Bull queue processing, and security best practices

File Upload Skill - IntelliFill

This skill provides comprehensive patterns for implementing secure file uploads in the IntelliFill project, covering frontend drag-and-drop, backend processing, validation, queue management, and security.


Table of Contents

  1. Frontend: React-Dropzone Integration
  2. Backend: Multer Configuration
  3. File Validation & Security
  4. Upload Progress Tracking
  5. Bull Queue Integration
  6. Error Handling Patterns
  7. Complete Implementation Examples
  8. Testing Strategies

Frontend: React-Dropzone Integration

Base File Upload Component

Location: quikadmin-web/src/components/features/file-upload-zone.tsx

The project uses a reusable FileUploadZone component built on react-dropzone:

import { useDropzone, type FileRejection, type DropzoneOptions } from "react-dropzone"
import { Upload, File, X, AlertCircle, CheckCircle2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"

interface FileUploadZoneProps {
  onFilesAccepted: (files: File[]) => void
  onFilesRejected?: (rejections: FileRejection[]) => void
  accept?: DropzoneOptions["accept"]
  maxSize?: number
  maxFiles?: number
  multiple?: boolean
  disabled?: boolean
  showFileList?: boolean
  uploadProgress?: number
  children?: React.ReactNode
}

export function FileUploadZone({
  onFilesAccepted,
  onFilesRejected,
  accept,
  maxSize = 10 * 1024 * 1024, // 10MB default
  maxFiles = 1,
  multiple = false,
  disabled = false,
  showFileList = true,
  uploadProgress,
  children,
}: FileUploadZoneProps) {
  const [acceptedFiles, setAcceptedFiles] = React.useState<File[]>([])
  const [rejectedFiles, setRejectedFiles] = React.useState<FileRejection[]>([])

  const onDrop = React.useCallback(
    (accepted: File[], rejected: FileRejection[]) => {
      setAcceptedFiles(accepted)
      setRejectedFiles(rejected)

      if (accepted.length > 0) {
        onFilesAccepted(accepted)
      }

      if (rejected.length > 0 && onFilesRejected) {
        onFilesRejected(rejected)
      }
    },
    [onFilesAccepted, onFilesRejected]
  )

  const {
    getRootProps,
    getInputProps,
    isDragActive,
    isDragAccept,
    isDragReject,
  } = useDropzone({
    onDrop,
    accept,
    maxSize,
    maxFiles,
    multiple,
    disabled,
  })

  return (
    <div data-slot="file-upload-zone" className="space-y-4">
      {/* Drop Zone */}
      <div
        {...getRootProps()}
        className={cn(
          "relative flex flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer",
          "hover:border-primary hover:bg-accent/50",
          isDragActive && "border-primary bg-accent/50",
          isDragAccept && "border-green-500 bg-green-50",
          isDragReject && "border-red-500 bg-red-50",
          disabled && "opacity-50 cursor-not-allowed pointer-events-none"
        )}
      >
        <input {...getInputProps()} />
        <Upload className="h-8 w-8 text-primary" />
        <p className="text-sm font-medium">
          {isDragActive ? "Drop files here" : "Drag and drop files here, or click to browse"}
        </p>
      </div>

      {/* Upload Progress */}
      {uploadProgress !== undefined && uploadProgress > 0 && uploadProgress < 100 && (
        <Progress value={uploadProgress} showPercentage label="Uploading..." />
      )}

      {/* Accepted Files List */}
      {showFileList && acceptedFiles.length > 0 && (
        <div className="space-y-2">
          {acceptedFiles.map((file, index) => (
            <div key={index} className="flex items-center gap-3 rounded-lg border p-3">
              <CheckCircle2 className="h-5 w-5 text-green-500" />
              <p className="text-sm font-medium">{file.name}</p>
            </div>
          ))}
        </div>
      )}

      {/* Rejected Files List */}
      {rejectedFiles.length > 0 && (
        <div className="space-y-2">
          {rejectedFiles.map(({ file, errors }, index) => (
            <div key={index} className="flex items-start gap-3 rounded-lg border border-destructive p-3">
              <AlertCircle className="h-5 w-5 text-destructive" />
              <div>
                <p className="text-sm font-medium">{file.name}</p>
                <ul className="text-xs text-destructive">
                  {errors.map((error) => (
                    <li key={error.code}>{error.message}</li>
                  ))}
                </ul>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

Usage Examples

PDF Upload (Single File)

<FileUploadZone
  onFilesAccepted={(files) => handlePDFUpload(files[0])}
  accept={{ 'application/pdf': ['.pdf'] }}
  maxSize={10 * 1024 * 1024} // 10MB
  maxFiles={1}
  uploadProgress={uploadProgress}
/>

Document Upload (Multiple Files)

<FileUploadZone
  multiple
  maxFiles={5}
  onFilesAccepted={handleMultipleUpload}
  onFilesRejected={(rejections) => {
    toast.error(`${rejections.length} files rejected`)
  }}
  accept={{
    'application/pdf': ['.pdf'],
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
    'text/plain': ['.txt'],
  }}
  maxSize={50 * 1024 * 1024} // 50MB for knowledge base docs
/>

Image Upload

<FileUploadZone
  onFilesAccepted={handleImageUpload}
  accept={{
    'image/jpeg': ['.jpg', '.jpeg'],
    'image/png': ['.png'],
    'image/tiff': ['.tif', '.tiff'],
  }}
  maxSize={5 * 1024 * 1024} // 5MB
/>

Backend: Multer Configuration

Standard Multer Configuration

Location: quikadmin/src/api/*.routes.ts

IntelliFill uses different Multer configurations based on upload type:

1. PDF Form Upload (10MB Limit)

import multer from 'multer';
import path from 'path';

// Basic configuration for form PDFs
const upload = multer({
  dest: 'uploads/',
  limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
  fileFilter: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    if (ext === '.pdf') {
      cb(null, true);
    } else {
      cb(new Error('Only PDF forms are supported'));
    }
  }
});

// Usage in route
router.post('/upload', authenticateSupabase, upload.single('form'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'Form file is required' });
  }

  // File available at req.file.path
  const filePath = req.file.path;
  // Process file...
});

2. Knowledge Base Documents (50MB Limit)

import multer from 'multer';
import path from 'path';

// Enhanced configuration with custom storage
const storage = multer.diskStorage({
  destination: 'uploads/knowledge/',
  filename: (req, file, cb) => {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    const ext = path.extname(file.originalname).toLowerCase();
    cb(null, `knowledge-${uniqueSuffix}${ext}`);
  },
});

const upload = multer({
  storage,
  limits: {
    fileSize: 50 * 1024 * 1024, // 50MB limit for knowledge documents
  },
  fileFilter: (req, file, cb) => {
    const allowedTypes = ['.pdf', '.docx', '.doc', '.txt', '.csv'];
    const ext = path.extname(file.originalname).toLowerCase();
    if (allowedTypes.includes(ext)) {
      cb(null, true);
    } else {
      cb(new Error(`File type ${ext} not supported for knowledge base`));
    }
  },
});

// Usage in route
router.post(
  '/sources/upload',
  authenticateSupabase,
  validateOrganization,
  upload.single('document'),
  async (req: Request, res: Response) => {
    if (!req.file) {
      return res.status(400).json({ error: 'Document file is required' });
    }

    // File metadata
    const { path, originalname, size, mimetype } = req.file;

    // Store in database
    const source = await prisma.documentSource.create({
      data: {
        organizationId: req.organizationId,
        userId: req.user.id,
        title: req.body.title,
        filename: originalname,
        fileSize: size,
        mimeType: mimetype,
        storageUrl: path,
        status: 'PENDING',
      },
    });

    res.status(201).json({ success: true, source });
  }
);

3. Memory-Optimized Configuration (Large Files)

import multer from 'multer';
import * as fs from 'fs/promises';

// For files that need immediate processing
const upload = multer({
  storage: multer.memoryStorage(), // Store in memory for immediate access
  limits: {
    fileSize: 10 * 1024 * 1024,
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    if (['.pdf', '.png', '.jpg'].includes(ext)) {
      cb(null, true);
    } else {
      cb(new Error('Invalid file type'));
    }
  },
});

router.post('/process', upload.single('document'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  // File buffer available at req.file.buffer
  const buffer = req.file.buffer;

  // Process immediately without saving to disk
  const result = await processBuffer(buffer);

  res.json({ success: true, result });
});

Multer Error Handling

import { MulterError } from 'multer';

router.post('/upload', upload.single('file'), (err, req, res, next) => {
  if (err instanceof MulterError) {
    if (err.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({
        error: 'File too large',
        maxSize: '10MB'
      });
    }
    if (err.code === 'LIMIT_FILE_COUNT') {
      return res.status(400).json({
        error: 'Too many files',
        maxFiles: 5
      });
    }
    return res.status(400).json({ error: err.message });
  }

  if (err) {
    return res.status(400).json({ error: err.message });
  }

  next();
}, async (req, res) => {
  // Normal upload handler
});

File Validation & Security

Comprehensive File Validation Service

Location: quikadmin/src/services/fileValidation.service.ts

IntelliFill includes a production-ready file validation service:

import { FileValidationService } from '@/services/fileValidation.service';

const validationService = new FileValidationService({
  maxFileSize: 10 * 1024 * 1024, // 10MB
  maxPages: 50,
  allowedMimeTypes: [
    'application/pdf',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'text/plain',
  ],
});

// In upload route
router.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  // Read file buffer
  const buffer = await fs.readFile(req.file.path);

  // Validate file
  const validation = await validationService.validateFile(
    buffer,
    req.file.originalname,
    req.file.mimetype
  );

  if (!validation.isValid) {
    // Delete invalid file
    await fs.unlink(req.file.path);
    return res.status(400).json({
      error: 'File validation failed',
      details: validation.errors,
      securityFlags: validation.securityFlags,
    });
  }

  // Log security flags for monitoring
  if (validation.securityFlags.length > 0) {
    logger.warn('File upload security flags', {
      filename: validation.sanitizedFilename,
      flags: validation.securityFlags,
      userId: req.user.id,
    });
  }

  // Proceed with processing
  // Use validation.sanitizedFilename for safe storage
});

Key Validation Features

  1. Magic Number Validation: Detects actual file type regardless of extension
  2. Path Traversal Prevention: Sanitizes filenames to prevent directory attacks
  3. PDF Security Scanning: Detects JavaScript, embedded files, suspicious patterns
  4. MIME Type Verification: Ensures declared type matches actual content
  5. File Size Limits: Enforces maximum file sizes
  6. Extension Validation: Verifies extension matches MIME type

Security Best Practices

// 1. Always sanitize filenames
const sanitizedName = validationService.sanitizeFilename(file.originalname);

// 2. Generate unique filenames to prevent overwrites
const uniqueName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${sanitizedName}`;

// 3. Use file hashing for deduplication
const fileHash = validationService.generateFileHash(buffer);

// 4. Check for path traversal
if (validationService.hasPathTraversal(filename)) {
  throw new Error('Invalid filename: path traversal detected');
}

// 5. Validate against allowed extensions
if (!validationService.validateExtension(filename, 'application/pdf')) {
  throw new Error('File extension does not match PDF type');
}

PDF-Specific Validation

// Deep PDF validation
const pdfValidation = await validationService.validatePDF(buffer);

if (!pdfValidation.isValid) {
  logger.error('Dangerous PDF detected', {
    errors: pdfValidation.errors,
    flags: pdfValidation.flags,
  });
  return res.status(400).json({
    error: 'PDF contains potentially dangerous content',
    details: pdfValidation.errors,
  });
}

// Check for specific threats
if (pdfValidation.flags.includes('PDF_CONTAINS_JAVASCRIPT')) {
  // Reject PDFs with JavaScript
  return res.status(400).json({
    error: 'PDFs with JavaScript are not allowed',
  });
}

if (pdfValidation.flags.includes('PDF_HAS_EMBEDDED_FILES')) {
  // Allow but log for monitoring
  logger.warn('PDF contains embedded files', {
    filename: sanitizedName,
    userId: req.user.id,
  });
}

Upload Progress Tracking

Frontend: Progress State Management

// Create upload store
import { create } from 'zustand';

interface UploadState {
  progress: number;
  uploading: boolean;
  error: string | null;

  setProgress: (progress: number) => void;
  setUploading: (uploading: boolean) => void;
  setError: (error: string | null) => void;
  reset: () => void;
}

export const useUploadStore = create<UploadState>((set) => ({
  progress: 0,
  uploading: false,
  error: null,

  setProgress: (progress) => set({ progress }),
  setUploading: (uploading) => set({ uploading }),
  setError: (error) => set({ error }),
  reset: () => set({ progress: 0, uploading: false, error: null }),
}));

Frontend: Upload with Progress

import axios from 'axios';
import { useUploadStore } from '@/stores/uploadStore';

async function uploadWithProgress(file: File) {
  const { setProgress, setUploading, setError } = useUploadStore.getState();

  const formData = new FormData();
  formData.append('document', file);

  setUploading(true);
  setError(null);
  setProgress(0);

  try {
    const response = await axios.post('/api/documents/upload', formData, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
      onUploadProgress: (progressEvent) => {
        const percentCompleted = Math.round(
          (progressEvent.loaded * 100) / (progressEvent.total || 1)
        );
        setProgress(percentCompleted);
      },
    });

    setProgress(100);
    return response.data;
  } catch (error) {
    setError(error.message);
    throw error;
  } finally {
    setUploading(false);
  }
}

Frontend: Progress UI Component

function UploadProgress() {
  const { progress, uploading, error } = useUploadStore();

  if (!uploading && progress === 0) return null;

  return (
    <div className="space-y-2">
      {error ? (
        <Alert variant="destructive">
          <AlertCircle className="h-4 w-4" />
          <AlertDescription>{error}</AlertDescription>
        </Alert>
      ) : (
        <>
          <Progress value={progress} />
          <p className="text-sm text-muted-foreground">
            Uploading... {progress}%
          </p>
        </>
      )}
    </div>
  );
}

Backend: Processing Status

// Update document status during processing
router.get('/documents/:id/status', authenticateSupabase, async (req, res) => {
  const { id } = req.params;
  const userId = req.user.id;

  const document = await prisma.document.findFirst({
    where: { id, userId },
    select: {
      id: true,
      fileName: true,
      status: true,
      confidence: true,
      processedAt: true,
    },
  });

  if (!document) {
    return res.status(404).json({ error: 'Document not found' });
  }

  // Try to find associated job status
  let jobStatus = null;
  try {
    const job = await getJobStatus(id);
    if (job) {
      jobStatus = {
        progress: job.progress,
        state: job.status,
        created: job.created_at,
      };
    }
  } catch (error) {
    logger.warn('Failed to fetch queue status:', error);
  }

  res.json({
    success: true,
    document,
    job: jobStatus,
  });
});

Bull Queue Integration

Document Queue Configuration

Location: quikadmin/src/queues/documentQueue.ts

import Bull from 'bull';
import { logger } from '../utils/logger';

export interface DocumentProcessingJob {
  documentId: string;
  userId: string;
  filePath: string;
  options?: {
    extractTables?: boolean;
    ocrEnabled?: boolean;
    language?: string;
    confidenceThreshold?: number;
  };
}

const redisConfig = {
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
};

// Create document processing queue
export const documentQueue = new Bull<DocumentProcessingJob>('document-processing', {
  redis: redisConfig,
  defaultJobOptions: {
    removeOnComplete: 100, // Keep last 100 completed jobs
    removeOnFail: 50, // Keep last 50 failed jobs
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 2000,
    },
  },
});

// Process document jobs
documentQueue.process(async (job) => {
  const { documentId, filePath, options } = job.data;

  try {
    // Update progress
    await job.progress(10);
    logger.info(`Processing document ${documentId}`);

    // Parse document
    await job.progress(30);
    const parsedContent = await parser.parse(filePath);

    // Extract data
    await job.progress(50);
    const extractedData = await extractor.extract(parsedContent);

    // Map fields
    await job.progress(70);
    const mappedFields = await mapper.mapFields(extractedData, []);

    // Complete
    await job.progress(100);

    return {
      documentId,
      status: 'completed',
      extractedData,
      mappedFields,
      processingTime: Date.now() - job.timestamp,
    };
  } catch (error) {
    logger.error(`Failed to process document ${documentId}:`, error);
    throw error;
  }
});

// Event handlers
documentQueue.on('completed', (job, result) => {
  logger.info(`Job ${job.id} completed`, { documentId: result.documentId });
});

documentQueue.on('failed', (job, err) => {
  logger.error(`Job ${job.id} failed:`, err);
});

// Get job status
export async function getJobStatus(jobId: string) {
  const job = await documentQueue.getJob(jobId);

  if (!job) {
    return null;
  }

  return {
    id: job.id,
    type: 'document_processing',
    status: await job.getState(),
    progress: job.progress(),
    created_at: new Date(job.timestamp),
    started_at: job.processedOn ? new Date(job.processedOn) : undefined,
    completed_at: job.finishedOn ? new Date(job.finishedOn) : undefined,
    result: job.returnvalue,
    error: job.failedReason,
  };
}

Adding Jobs to Queue

import { documentQueue } from '@/queues/documentQueue';

// In upload route
router.post('/upload', authenticateSupabase, upload.single('document'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  const userId = req.user.id;

  // Validate file
  const buffer = await fs.readFile(req.file.path);
  const validation = await validationService.validateFile(
    buffer,
    req.file.originalname,
    req.file.mimetype
  );

  if (!validation.isValid) {
    await fs.unlink(req.file.path);
    return res.status(400).json({ error: 'Invalid file', details: validation.errors });
  }

  // Create document record
  const document = await prisma.document.create({
    data: {
      userId,
      fileName: validation.sanitizedFilename,
      fileType: validation.detectedMimeType || req.file.mimetype,
      fileSize: req.file.size,
      storageUrl: req.file.path,
      status: 'PENDING',
    },
  });

  // Add to processing queue
  const job = await documentQueue.add({
    documentId: document.id,
    userId,
    filePath: req.file.path,
    options: {
      extractTables: true,
      ocrEnabled: true,
      language: 'eng',
    },
  });

  res.status(201).json({
    success: true,
    document: {
      id: document.id,
      fileName: document.fileName,
      status: document.status,
    },
    job: {
      id: job.id,
      status: 'queued',
    },
    statusUrl: `/api/documents/${document.id}/status`,
  });
});

Batch Processing

export const batchQueue = new Bull<BatchProcessingJob>('batch-processing', {
  redis: redisConfig,
  defaultJobOptions: {
    removeOnComplete: 50,
    removeOnFail: 25,
    attempts: 2,
  },
});

interface BatchProcessingJob {
  documentIds: string[];
  userId: string;
  targetFormId?: string;
  options?: {
    parallel?: boolean;
    stopOnError?: boolean;
  };
}

// Process batch jobs
batchQueue.process(async (job) => {
  const { documentIds, userId, options } = job.data;
  const results = [];

  for (let i = 0; i < documentIds.length; i++) {
    const progress = Math.round((i / documentIds.length) * 100);
    await job.progress(progress);

    // Add individual document to processing queue
    const childJob = await documentQueue.add({
      documentId: documentIds[i],
      userId,
      filePath: `pending`, // Fetched from database
      options: {},
    });

    // Wait for completion if not parallel
    if (!options?.parallel) {
      const result = await childJob.finished();
      results.push(result);

      // Stop on error if configured
      if (options?.stopOnError && result.status === 'failed') {
        break;
      }
    } else {
      results.push({ documentId: documentIds[i], jobId: childJob.id });
    }
  }

  await job.progress(100);
  return {
    batchId: job.id,
    documentsProcessed: results.length,
    results,
  };
});

Error Handling Patterns

Frontend Error Handling

import { toast } from '@/components/ui/use-toast';

async function handleUpload(files: File[]) {
  const { setUploading, setError, reset } = useUploadStore.getState();

  reset();

  try {
    setUploading(true);

    for (const file of files) {
      const result = await uploadWithProgress(file);

      toast({
        title: 'Upload successful',
        description: `${file.name} uploaded successfully`,
      });
    }
  } catch (error) {
    const message = error.response?.data?.error || error.message || 'Upload failed';

    setError(message);

    toast({
      title: 'Upload failed',
      description: message,
      variant: 'destructive',
    });

    logger.error('Upload error:', error);
  } finally {
    setUploading(false);
  }
}

Backend Error Handling

router.post('/upload', authenticateSupabase, upload.single('file'), async (req, res, next) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'No file uploaded' });
    }

    // Validate file
    const buffer = await fs.readFile(req.file.path);
    const validation = await validationService.validateFile(
      buffer,
      req.file.originalname,
      req.file.mimetype
    );

    if (!validation.isValid) {
      // Cleanup
      await fs.unlink(req.file.path).catch(() => {});

      return res.status(400).json({
        error: 'File validation failed',
        details: validation.errors,
        securityFlags: validation.securityFlags,
      });
    }

    // Process file...

  } catch (error) {
    // Cleanup on error
    if (req.file?.path) {
      await fs.unlink(req.file.path).catch(() => {});
    }

    logger.error('Upload error:', {
      error,
      userId: req.user?.id,
      filename: req.file?.originalname,
    });

    next(error);
  }
});

// Global error handler
app.use((err, req, res, next) => {
  if (err instanceof MulterError) {
    return res.status(400).json({
      error: 'Upload error',
      message: err.message,
      code: err.code,
    });
  }

  if (err.message?.includes('validation')) {
    return res.status(400).json({
      error: 'Validation error',
      message: err.message,
    });
  }

  logger.error('Unhandled error:', err);
  res.status(500).json({ error: 'Internal server error' });
});

Queue Error Handling

documentQueue.on('failed', async (job, err) => {
  logger.error(`Job ${job.id} failed:`, {
    error: err,
    documentId: job.data.documentId,
    attempts: job.attemptsMade,
  });

  // Update document status
  try {
    await prisma.document.update({
      where: { id: job.data.documentId },
      data: {
        status: 'FAILED',
        errorMessage: err.message,
      },
    });
  } catch (dbError) {
    logger.error('Failed to update document status:', dbError);
  }

  // Notify user via websocket or email
  // await notifyUser(job.data.userId, 'Document processing failed');
});

documentQueue.on('stalled', (job) => {
  logger.warn(`Job ${job.id} stalled`, {
    documentId: job.data.documentId,
  });
});

Complete Implementation Examples

Example 1: Simple PDF Upload Page

// pages/Upload.tsx
import { useState } from 'react';
import { FileUploadZone } from '@/components/features/file-upload-zone';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { useUploadStore } from '@/stores/uploadStore';
import { uploadDocument } from '@/services/documentService';

export function UploadPage() {
  const { progress, uploading } = useUploadStore();
  const [uploadedDocs, setUploadedDocs] = useState([]);

  const handleUpload = async (files: File[]) => {
    for (const file of files) {
      const result = await uploadDocument(file);
      setUploadedDocs(prev => [...prev, result]);
    }
  };

  return (
    <div className="container mx-auto p-6">
      <Card>
        <CardHeader>
          <h2 className="text-2xl font-bold">Upload Documents</h2>
        </CardHeader>
        <CardContent>
          <FileUploadZone
            onFilesAccepted={handleUpload}
            accept={{ 'application/pdf': ['.pdf'] }}
            maxSize={10 * 1024 * 1024}
            uploadProgress={progress}
            disabled={uploading}
          />

          {uploadedDocs.length > 0 && (
            <div className="mt-6">
              <h3 className="font-semibold mb-2">Uploaded Documents</h3>
              <ul className="space-y-2">
                {uploadedDocs.map(doc => (
                  <li key={doc.id}>{doc.fileName} - {doc.status}</li>
                ))}
              </ul>
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

Example 2: Knowledge Base Upload with Processing

// services/knowledgeService.ts
import axios from 'axios';
import { useUploadStore } from '@/stores/uploadStore';

export async function uploadKnowledgeDocument(file: File, title: string) {
  const { setProgress, setUploading, setError } = useUploadStore.getState();

  const formData = new FormData();
  formData.append('document', file);
  formData.append('title', title);

  setUploading(true);
  setError(null);

  try {
    const response = await axios.post('/api/knowledge/sources/upload', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: (e) => {
        const percent = Math.round((e.loaded * 100) / (e.total || 1));
        setProgress(percent);
      },
    });

    return response.data;
  } catch (error) {
    setError(error.response?.data?.error || 'Upload failed');
    throw error;
  } finally {
    setUploading(false);
  }
}

// Component
function KnowledgeUpload() {
  const [title, setTitle] = useState('');
  const { progress, uploading, error } = useUploadStore();

  const handleUpload = async (files: File[]) => {
    const file = files[0];

    try {
      const result = await uploadKnowledgeDocument(file, title || file.name);
      toast({ title: 'Upload successful', description: result.message });
    } catch (error) {
      toast({ title: 'Upload failed', variant: 'destructive' });
    }
  };

  return (
    <div className="space-y-4">
      <Input
        placeholder="Document title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />

      <FileUploadZone
        onFilesAccepted={handleUpload}
        accept={{
          'application/pdf': ['.pdf'],
          'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
          'text/plain': ['.txt'],
        }}
        maxSize={50 * 1024 * 1024}
        uploadProgress={progress}
        disabled={uploading || !title}
      />

      {error && <Alert variant="destructive">{error}</Alert>}
    </div>
  );
}

Testing Strategies

Frontend Tests

// components/FileUploadZone.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { FileUploadZone } from './file-upload-zone';

describe('FileUploadZone', () => {
  it('accepts valid PDF files', async () => {
    const onFilesAccepted = vi.fn();

    render(
      <FileUploadZone
        onFilesAccepted={onFilesAccepted}
        accept={{ 'application/pdf': ['.pdf'] }}
      />
    );

    const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText('File upload input');

    fireEvent.change(input, { target: { files: [file] } });

    await waitFor(() => {
      expect(onFilesAccepted).toHaveBeenCalledWith([file]);
    });
  });

  it('rejects files exceeding size limit', async () => {
    const onFilesRejected = vi.fn();

    render(
      <FileUploadZone
        onFilesAccepted={vi.fn()}
        onFilesRejected={onFilesRejected}
        maxSize={1024} // 1KB
      />
    );

    const largeFile = new File(['x'.repeat(2048)], 'large.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText('File upload input');

    fireEvent.change(input, { target: { files: [largeFile] } });

    await waitFor(() => {
      expect(onFilesRejected).toHaveBeenCalled();
    });
  });
});

Backend Tests

// api/documents.routes.test.ts
import request from 'supertest';
import { app } from '../app';
import * as fs from 'fs/promises';

describe('POST /api/documents/upload', () => {
  it('uploads valid PDF file', async () => {
    const response = await request(app)
      .post('/api/documents/upload')
      .set('Authorization', `Bearer ${validToken}`)
      .attach('document', 'test/fixtures/valid.pdf')
      .expect(201);

    expect(response.body.success).toBe(true);
    expect(response.body.document.id).toBeDefined();
  });

  it('rejects non-PDF files', async () => {
    const response = await request(app)
      .post('/api/documents/upload')
      .set('Authorization', `Bearer ${validToken}`)
      .attach('document', 'test/fixtures/test.txt')
      .expect(400);

    expect(response.body.error).toContain('Only PDF forms are supported');
  });

  it('rejects files exceeding size limit', async () => {
    // Create 15MB file (exceeds 10MB limit)
    const largePath = 'test/fixtures/large.pdf';
    await fs.writeFile(largePath, Buffer.alloc(15 * 1024 * 1024));

    const response = await request(app)
      .post('/api/documents/upload')
      .set('Authorization', `Bearer ${validToken}`)
      .attach('document', largePath)
      .expect(400);

    expect(response.body.error).toContain('File too large');

    await fs.unlink(largePath);
  });
});

Validation Service Tests

// services/fileValidation.service.test.ts
import { FileValidationService } from '../fileValidation.service';
import * as fs from 'fs/promises';

describe('FileValidationService', () => {
  const service = new FileValidationService();

  it('detects PDF magic numbers', async () => {
    const buffer = await fs.readFile('test/fixtures/valid.pdf');
    const result = await service.validateFile(buffer, 'test.pdf', 'application/pdf');

    expect(result.isValid).toBe(true);
    expect(result.detectedMimeType).toBe('application/pdf');
  });

  it('rejects files with mismatched MIME types', async () => {
    const buffer = Buffer.from('plain text');
    const result = await service.validateFile(buffer, 'fake.pdf', 'application/pdf');

    expect(result.isValid).toBe(false);
    expect(result.securityFlags).toContain('MIME_TYPE_MISMATCH');
  });

  it('sanitizes dangerous filenames', () => {
    const dangerous = '../../../etc/passwd';
    const sanitized = service.sanitizeFilename(dangerous);

    expect(sanitized).not.toContain('..');
    expect(sanitized).not.toContain('/');
  });

  it('detects JavaScript in PDFs', async () => {
    const pdfWithJS = Buffer.from('%PDF-1.4\n/JavaScript (alert(1))');
    const result = await service.validatePDF(pdfWithJS);

    expect(result.isValid).toBe(false);
    expect(result.flags).toContain('PDF_CONTAINS_JAVASCRIPT');
  });
});

Key Takeaways

  1. Frontend: Use FileUploadZone component with react-dropzone for consistent UI
  2. Backend: Configure Multer based on file type and size requirements
  3. Security: Always validate files with FileValidationService before processing
  4. Progress: Track upload progress with Zustand store and axios onUploadProgress
  5. Queues: Use Bull queues for async processing with progress tracking
  6. Error Handling: Implement comprehensive error handling at all layers
  7. Testing: Test validation, upload, and error scenarios

Related Files

Frontend:

  • quikadmin-web/src/components/features/file-upload-zone.tsx
  • quikadmin-web/src/stores/uploadStore.ts
  • quikadmin-web/src/services/documentService.ts

Backend:

  • quikadmin/src/api/documents.routes.ts
  • quikadmin/src/api/knowledge.routes.ts
  • quikadmin/src/services/fileValidation.service.ts
  • quikadmin/src/queues/documentQueue.ts

Configuration:

  • quikadmin/package.json (multer, bull dependencies)
  • quikadmin-web/package.json (react-dropzone dependency)