Implements cloud-based file storage for plant photos, documents, and certificates: Storage Layer: - Multi-provider support (AWS S3, Cloudflare R2, MinIO, local filesystem) - S3-compatible provider with presigned URL generation - Local storage provider for development with signed URL verification - Configurable via environment variables Image Processing: - Automatic thumbnail generation (150x150, 300x300, 600x600, 1200x1200) - WebP conversion for optimized file sizes - EXIF data extraction for image metadata - Image optimization with Sharp API Endpoints: - POST /api/upload/image - Upload images with automatic processing - POST /api/upload/document - Upload documents (PDF, DOC, DOCX) - POST /api/upload/presigned - Get presigned URLs for direct uploads - GET/DELETE /api/upload/[fileId] - File management UI Components: - ImageUploader - Drag & drop image upload with preview - PhotoGallery - Grid gallery with lightbox view - DocumentUploader - Document upload with file type icons - ProgressBar - Animated upload progress indicator Database: - FileStore service with in-memory storage (Prisma schema ready for Agent 2) - File metadata tracking with soft delete support - Category-based file organization
160 lines
4.6 KiB
TypeScript
160 lines
4.6 KiB
TypeScript
/**
|
|
* Document Upload API Endpoint
|
|
* Agent 3: File Upload & Storage System
|
|
*
|
|
* POST /api/upload/document - Upload a document file (PDF, DOC, etc.)
|
|
*/
|
|
|
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
import { getUploadService } from '../../../lib/storage';
|
|
import type { FileCategory } from '../../../lib/storage/types';
|
|
|
|
// Disable body parser to handle multipart/form-data
|
|
export const config = {
|
|
api: {
|
|
bodyParser: false,
|
|
},
|
|
};
|
|
|
|
interface UploadResponse {
|
|
success: boolean;
|
|
file?: {
|
|
id: string;
|
|
url: string;
|
|
size: number;
|
|
mimeType: string;
|
|
originalName: string;
|
|
};
|
|
error?: string;
|
|
}
|
|
|
|
async function parseMultipartForm(
|
|
req: NextApiRequest
|
|
): Promise<{ buffer: Buffer; filename: string; mimeType: string; fields: Record<string, string> }> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
let filename = 'document';
|
|
let mimeType = 'application/octet-stream';
|
|
const fields: Record<string, string> = {};
|
|
|
|
const contentType = req.headers['content-type'] || '';
|
|
const boundary = contentType.split('boundary=')[1];
|
|
|
|
if (!boundary) {
|
|
reject(new Error('Missing multipart boundary'));
|
|
return;
|
|
}
|
|
|
|
req.on('data', (chunk: Buffer) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
req.on('end', () => {
|
|
const buffer = Buffer.concat(chunks);
|
|
const content = buffer.toString('binary');
|
|
const parts = content.split(`--${boundary}`);
|
|
|
|
for (const part of parts) {
|
|
if (part.includes('Content-Disposition: form-data')) {
|
|
const nameMatch = part.match(/name="([^"]+)"/);
|
|
const filenameMatch = part.match(/filename="([^"]+)"/);
|
|
|
|
if (filenameMatch) {
|
|
filename = filenameMatch[1];
|
|
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
|
|
if (contentTypeMatch) {
|
|
mimeType = contentTypeMatch[1].trim();
|
|
}
|
|
|
|
const contentStart = part.indexOf('\r\n\r\n') + 4;
|
|
const contentEnd = part.lastIndexOf('\r\n');
|
|
if (contentStart > 4 && contentEnd > contentStart) {
|
|
const fileContent = part.slice(contentStart, contentEnd);
|
|
const fileBuffer = Buffer.from(fileContent, 'binary');
|
|
resolve({ buffer: fileBuffer, filename, mimeType, fields });
|
|
return;
|
|
}
|
|
} else if (nameMatch) {
|
|
const fieldName = nameMatch[1];
|
|
const contentStart = part.indexOf('\r\n\r\n') + 4;
|
|
const contentEnd = part.lastIndexOf('\r\n');
|
|
if (contentStart > 4 && contentEnd > contentStart) {
|
|
fields[fieldName] = part.slice(contentStart, contentEnd).trim();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
reject(new Error('No file found in request'));
|
|
});
|
|
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
export default async function handler(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse<UploadResponse>
|
|
) {
|
|
if (req.method !== 'POST') {
|
|
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
|
}
|
|
|
|
try {
|
|
const { buffer, filename, mimeType, fields } = await parseMultipartForm(req);
|
|
|
|
// Validate document type
|
|
const allowedTypes = [
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'image/jpeg',
|
|
'image/png',
|
|
];
|
|
|
|
if (!allowedTypes.includes(mimeType)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid file type. Only PDF, DOC, DOCX, and images are allowed.',
|
|
});
|
|
}
|
|
|
|
const category = (fields.category as FileCategory) || 'document';
|
|
const plantId = fields.plantId;
|
|
const farmId = fields.farmId;
|
|
const userId = fields.userId;
|
|
|
|
const uploadService = getUploadService();
|
|
const result = await uploadService.upload(buffer, filename, mimeType, {
|
|
category,
|
|
plantId,
|
|
farmId,
|
|
userId,
|
|
generateThumbnails: false, // No thumbnails for documents
|
|
});
|
|
|
|
if (!result.success || !result.file) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: result.error || 'Upload failed',
|
|
});
|
|
}
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
file: {
|
|
id: result.file.id,
|
|
url: result.file.url,
|
|
size: result.file.size,
|
|
mimeType: result.file.mimeType,
|
|
originalName: result.file.originalName,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Document upload error:', error);
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Internal server error',
|
|
});
|
|
}
|
|
}
|