Merge: File Upload & Storage System (Agent 3) - resolved conflicts

This commit is contained in:
Vinnie Esposito 2025-11-23 10:59:54 -06:00
commit b0dc9fca4d
19 changed files with 2918 additions and 196 deletions

518
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,236 @@
/**
* Document Uploader Component
* Agent 3: File Upload & Storage System
*
* Upload interface for documents (PDF, DOC, etc.)
*/
import React, { useState, useCallback, useRef } from 'react';
import type { FileCategory } from '../../lib/storage/types';
import ProgressBar from './ProgressBar';
interface UploadedDocument {
id: string;
url: string;
size: number;
mimeType: string;
originalName: string;
}
interface DocumentUploaderProps {
category?: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
onUpload?: (file: UploadedDocument) => void;
onError?: (error: string) => void;
accept?: string;
className?: string;
}
export function DocumentUploader({
category = 'document',
plantId,
farmId,
userId,
onUpload,
onError,
accept = '.pdf,.doc,.docx',
className = '',
}: DocumentUploaderProps) {
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string>();
const [uploadedFile, setUploadedFile] = useState<UploadedDocument | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadFile = async (file: File) => {
setIsUploading(true);
setProgress(10);
setError(undefined);
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (plantId) formData.append('plantId', plantId);
if (farmId) formData.append('farmId', farmId);
if (userId) formData.append('userId', userId);
try {
setProgress(30);
const response = await fetch('/api/upload/document', {
method: 'POST',
body: formData,
});
setProgress(80);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
setProgress(100);
setUploadedFile(data.file);
onUpload?.(data.file);
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed';
setError(message);
onError?.(message);
} finally {
setIsUploading(false);
}
};
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
await uploadFile(file);
}
},
[]
);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setUploadedFile(null);
setProgress(0);
setError(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const getFileIcon = (mimeType: string) => {
if (mimeType === 'application/pdf') {
return (
<svg className="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zm-3 9.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zm3 3c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5z" />
</svg>
);
}
return (
<svg className="w-8 h-8 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
</svg>
);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{uploadedFile ? (
<div className="flex items-center p-4 border rounded-lg bg-gray-50">
{getFileIcon(uploadedFile.mimeType)}
<div className="ml-3 flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{uploadedFile.originalName}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(uploadedFile.size)}
</p>
</div>
<div className="flex items-center space-x-2">
<a
href={uploadedFile.url}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 hover:text-green-700"
title="Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
<button
onClick={handleRemove}
className="text-red-500 hover:text-red-600"
title="Remove"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
) : (
<button
onClick={handleClick}
disabled={isUploading}
className={`
w-full border-2 border-dashed rounded-lg p-6 text-center
transition-colors duration-200
${isUploading
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50 cursor-pointer'
}
`}
>
<svg
className="mx-auto h-10 w-10 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="text-green-600 font-medium">Click to upload</span>
{' '}a document
</p>
<p className="mt-1 text-xs text-gray-500">
PDF, DOC, DOCX up to 10MB
</p>
</button>
)}
{isUploading && (
<div className="mt-3">
<ProgressBar progress={progress} />
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
</div>
)}
{error && (
<p className="mt-2 text-sm text-red-500 text-center">{error}</p>
)}
</div>
);
}
export default DocumentUploader;

View file

@ -0,0 +1,266 @@
/**
* Image Uploader Component
* Agent 3: File Upload & Storage System
*
* Drag & drop image upload with preview and progress
*/
import React, { useState, useCallback, useRef } from 'react';
import type { FileCategory } from '../../lib/storage/types';
interface UploadedFile {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
size: number;
}
interface ImageUploaderProps {
category?: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
onUpload?: (file: UploadedFile) => void;
onError?: (error: string) => void;
maxFiles?: number;
accept?: string;
className?: string;
}
interface UploadState {
isUploading: boolean;
progress: number;
error?: string;
preview?: string;
}
export function ImageUploader({
category = 'plant-photo',
plantId,
farmId,
userId,
onUpload,
onError,
maxFiles = 1,
accept = 'image/*',
className = '',
}: ImageUploaderProps) {
const [uploadState, setUploadState] = useState<UploadState>({
isUploading: false,
progress: 0,
});
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const uploadFile = async (file: File) => {
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setUploadState((prev) => ({
...prev,
preview: e.target?.result as string,
}));
};
reader.readAsDataURL(file);
// Start upload
setUploadState((prev) => ({
...prev,
isUploading: true,
progress: 0,
error: undefined,
}));
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (plantId) formData.append('plantId', plantId);
if (farmId) formData.append('farmId', farmId);
if (userId) formData.append('userId', userId);
try {
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
setUploadState({
isUploading: false,
progress: 100,
preview: data.file.thumbnailUrl || data.file.url,
});
onUpload?.(data.file);
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed';
setUploadState((prev) => ({
...prev,
isUploading: false,
error: message,
}));
onError?.(message);
}
};
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setUploadState({
isUploading: false,
progress: 0,
preview: undefined,
error: undefined,
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className={`relative ${className}`}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{uploadState.preview ? (
<div className="relative rounded-lg overflow-hidden border-2 border-green-200">
<img
src={uploadState.preview}
alt="Uploaded preview"
className="w-full h-48 object-cover"
/>
<button
onClick={handleRemove}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
title="Remove image"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
transition-colors duration-200
${isDragging
? 'border-green-500 bg-green-50'
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50'
}
${uploadState.isUploading ? 'pointer-events-none opacity-50' : ''}
`}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="text-green-600 font-medium">Click to upload</span>
{' '}or drag and drop
</p>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG, GIF, WEBP up to 5MB
</p>
</div>
)}
{uploadState.isUploading && (
<div className="mt-2">
<div className="bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadState.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
</div>
)}
{uploadState.error && (
<p className="mt-2 text-sm text-red-500 text-center">{uploadState.error}</p>
)}
</div>
);
}
export default ImageUploader;

View file

@ -0,0 +1,213 @@
/**
* Photo Gallery Component
* Agent 3: File Upload & Storage System
*
* Displays a grid of plant photos with lightbox view
*/
import React, { useState } from 'react';
interface Photo {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
caption?: string;
uploadedAt?: string;
}
interface PhotoGalleryProps {
photos: Photo[];
onDelete?: (photoId: string) => void;
editable?: boolean;
columns?: 2 | 3 | 4;
className?: string;
}
export function PhotoGallery({
photos,
onDelete,
editable = false,
columns = 3,
className = '',
}: PhotoGalleryProps) {
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const handleDelete = async (photoId: string) => {
if (!onDelete) return;
setIsDeleting(photoId);
try {
await onDelete(photoId);
} finally {
setIsDeleting(null);
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-2 sm:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4',
};
if (photos.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="mt-2 text-sm text-gray-500">No photos yet</p>
</div>
);
}
return (
<>
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
{photos.map((photo) => (
<div
key={photo.id}
className="relative group aspect-square rounded-lg overflow-hidden bg-gray-100"
>
<img
src={photo.thumbnailUrl || photo.url}
alt={photo.caption || 'Plant photo'}
className="w-full h-full object-cover cursor-pointer transition-transform duration-200 group-hover:scale-105"
onClick={() => setSelectedPhoto(photo)}
loading="lazy"
/>
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
<button
onClick={() => setSelectedPhoto(photo)}
className="opacity-0 group-hover:opacity-100 bg-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-gray-100"
title="View full size"
>
<svg
className="w-5 h-5 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</button>
{editable && onDelete && (
<button
onClick={() => handleDelete(photo.id)}
disabled={isDeleting === photo.id}
className="opacity-0 group-hover:opacity-100 bg-red-500 text-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-red-600 disabled:opacity-50"
title="Delete photo"
>
{isDeleting === photo.id ? (
<svg
className="w-5 h-5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
)}
</button>
)}
</div>
{/* Caption */}
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.caption}</p>
</div>
)}
</div>
))}
</div>
{/* Lightbox */}
{selectedPhoto && (
<div
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
onClick={() => setSelectedPhoto(null)}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
onClick={() => setSelectedPhoto(null)}
>
<svg
className="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<img
src={selectedPhoto.url}
alt={selectedPhoto.caption || 'Plant photo'}
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{selectedPhoto.caption && (
<div className="absolute bottom-4 left-4 right-4 text-center">
<p className="text-white text-lg">{selectedPhoto.caption}</p>
</div>
)}
</div>
)}
</>
);
}
export default PhotoGallery;

View file

@ -0,0 +1,63 @@
/**
* Progress Bar Component
* Agent 3: File Upload & Storage System
*
* Animated progress bar for uploads
*/
import React from 'react';
interface ProgressBarProps {
progress: number;
showPercentage?: boolean;
color?: 'green' | 'blue' | 'purple' | 'orange';
size?: 'sm' | 'md' | 'lg';
animated?: boolean;
className?: string;
}
const colorClasses = {
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
orange: 'bg-orange-500',
};
const sizeClasses = {
sm: 'h-1',
md: 'h-2',
lg: 'h-3',
};
export function ProgressBar({
progress,
showPercentage = false,
color = 'green',
size = 'md',
animated = true,
className = '',
}: ProgressBarProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className={className}>
<div className={`bg-gray-200 rounded-full overflow-hidden ${sizeClasses[size]}`}>
<div
className={`
${colorClasses[color]} ${sizeClasses[size]} rounded-full
transition-all duration-300 ease-out
${animated && clampedProgress < 100 ? 'animate-pulse' : ''}
`}
style={{ width: `${clampedProgress}%` }}
/>
</div>
{showPercentage && (
<p className="text-xs text-gray-500 mt-1 text-right">
{Math.round(clampedProgress)}%
</p>
)}
</div>
);
}
export default ProgressBar;

View file

@ -0,0 +1,11 @@
/**
* Upload Components Index
* Agent 3: File Upload & Storage System
*
* Export all upload-related components
*/
export { ImageUploader } from './ImageUploader';
export { PhotoGallery } from './PhotoGallery';
export { DocumentUploader } from './DocumentUploader';
export { ProgressBar } from './ProgressBar';

87
lib/storage/config.ts Normal file
View file

@ -0,0 +1,87 @@
/**
* Storage Configuration for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Supports multiple storage providers: AWS S3, Cloudflare R2, MinIO, or local filesystem
*/
import { StorageConfig, StorageProvider } from './types';
function getStorageProvider(): StorageProvider {
const provider = process.env.STORAGE_PROVIDER as StorageProvider;
if (provider && ['s3', 'r2', 'minio', 'local'].includes(provider)) {
return provider;
}
return 'local'; // Default to local storage for development
}
export function getStorageConfig(): StorageConfig {
const provider = getStorageProvider();
const baseConfig: StorageConfig = {
provider,
bucket: process.env.STORAGE_BUCKET || 'localgreenchain',
region: process.env.STORAGE_REGION || 'us-east-1',
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID,
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY,
publicUrl: process.env.STORAGE_PUBLIC_URL,
};
switch (provider) {
case 's3':
return {
...baseConfig,
endpoint: process.env.AWS_S3_ENDPOINT,
};
case 'r2':
return {
...baseConfig,
endpoint: process.env.CLOUDFLARE_R2_ENDPOINT ||
`https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: 'auto',
};
case 'minio':
return {
...baseConfig,
endpoint: process.env.MINIO_ENDPOINT || 'http://localhost:9000',
region: 'us-east-1',
};
case 'local':
default:
return {
...baseConfig,
localPath: process.env.LOCAL_STORAGE_PATH || './uploads',
publicUrl: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3001',
};
}
}
export const storageConfig = getStorageConfig();
/**
* Environment variable template for storage configuration:
*
* # Storage Provider (s3, r2, minio, local)
* STORAGE_PROVIDER=local
* STORAGE_BUCKET=localgreenchain
* STORAGE_REGION=us-east-1
* STORAGE_ACCESS_KEY_ID=your-access-key
* STORAGE_SECRET_ACCESS_KEY=your-secret-key
* STORAGE_PUBLIC_URL=https://cdn.yourdomain.com
*
* # For AWS S3
* AWS_S3_ENDPOINT=https://s3.amazonaws.com
*
* # For Cloudflare R2
* CLOUDFLARE_ACCOUNT_ID=your-account-id
* CLOUDFLARE_R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
*
* # For MinIO
* MINIO_ENDPOINT=http://localhost:9000
*
* # For Local Storage
* LOCAL_STORAGE_PATH=./uploads
*/

258
lib/storage/fileStore.ts Normal file
View file

@ -0,0 +1,258 @@
/**
* File Store for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* In-memory file metadata store (to be replaced with Prisma when Agent 2 completes)
*
* Prisma Schema (for Agent 2 to integrate):
*
* model File {
* id String @id @default(cuid())
* filename String // Storage path/key
* originalName String // Original uploaded filename
* mimeType String
* size Int // File size in bytes
* category String // plant-photo, certificate, document, report, avatar
* url String // Public URL
* thumbnailUrl String? // Thumbnail URL (for images)
* urls Json? // All size variants URLs
* width Int? // Image width
* height Int? // Image height
* exifData Json? // EXIF metadata
* uploadedById String? // User who uploaded
* plantId String? // Associated plant
* farmId String? // Associated farm
* createdAt DateTime @default(now())
* updatedAt DateTime @updatedAt
* deletedAt DateTime? // Soft delete
*
* uploadedBy User? @relation(fields: [uploadedById], references: [id])
* plant Plant? @relation(fields: [plantId], references: [id])
* farm VerticalFarm? @relation(fields: [farmId], references: [id])
*
* @@index([category])
* @@index([plantId])
* @@index([farmId])
* @@index([uploadedById])
* }
*/
import { FileMetadata, FileCategory, ImageSize } from './types';
/**
* In-memory file store
* This is a temporary implementation until Prisma is set up by Agent 2
*/
class FileStore {
private files: Map<string, FileMetadata> = new Map();
/**
* Save file metadata
*/
async save(metadata: FileMetadata): Promise<FileMetadata> {
this.files.set(metadata.id, {
...metadata,
updatedAt: new Date(),
});
return metadata;
}
/**
* Get file by ID
*/
async getById(id: string): Promise<FileMetadata | null> {
const file = this.files.get(id);
if (!file || file.deletedAt) {
return null;
}
return file;
}
/**
* Get file by filename/key
*/
async getByFilename(filename: string): Promise<FileMetadata | null> {
for (const file of this.files.values()) {
if (file.filename === filename && !file.deletedAt) {
return file;
}
}
return null;
}
/**
* Get files by plant ID
*/
async getByPlantId(plantId: string): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.plantId === plantId && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Get files by farm ID
*/
async getByFarmId(farmId: string): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.farmId === farmId && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Get files by user ID
*/
async getByUserId(userId: string): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.uploadedBy === userId && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Get files by category
*/
async getByCategory(category: FileCategory): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.category === category && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Update file metadata
*/
async update(id: string, updates: Partial<FileMetadata>): Promise<FileMetadata | null> {
const existing = this.files.get(id);
if (!existing) {
return null;
}
const updated: FileMetadata = {
...existing,
...updates,
id, // Ensure ID cannot be changed
updatedAt: new Date(),
};
this.files.set(id, updated);
return updated;
}
/**
* Soft delete a file
*/
async softDelete(id: string): Promise<boolean> {
const existing = this.files.get(id);
if (!existing) {
return false;
}
this.files.set(id, {
...existing,
deletedAt: new Date(),
updatedAt: new Date(),
});
return true;
}
/**
* Hard delete a file
*/
async hardDelete(id: string): Promise<boolean> {
return this.files.delete(id);
}
/**
* List all files with pagination
*/
async list(options: {
limit?: number;
offset?: number;
includeDeleted?: boolean;
} = {}): Promise<{ files: FileMetadata[]; total: number }> {
const { limit = 50, offset = 0, includeDeleted = false } = options;
let results = Array.from(this.files.values());
if (!includeDeleted) {
results = results.filter((f) => !f.deletedAt);
}
// Sort by creation date descending
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const total = results.length;
const files = results.slice(offset, offset + limit);
return { files, total };
}
/**
* Get storage statistics
*/
async getStats(): Promise<{
totalFiles: number;
totalSize: number;
byCategory: Record<FileCategory, { count: number; size: number }>;
}> {
const stats: {
totalFiles: number;
totalSize: number;
byCategory: Record<FileCategory, { count: number; size: number }>;
} = {
totalFiles: 0,
totalSize: 0,
byCategory: {
'plant-photo': { count: 0, size: 0 },
'certificate': { count: 0, size: 0 },
'document': { count: 0, size: 0 },
'report': { count: 0, size: 0 },
'avatar': { count: 0, size: 0 },
},
};
for (const file of this.files.values()) {
if (!file.deletedAt) {
stats.totalFiles++;
stats.totalSize += file.size;
stats.byCategory[file.category].count++;
stats.byCategory[file.category].size += file.size;
}
}
return stats;
}
/**
* Clear all files (for testing)
*/
clear(): void {
this.files.clear();
}
}
// Singleton instance
let fileStoreInstance: FileStore | null = null;
export function getFileStore(): FileStore {
if (!fileStoreInstance) {
fileStoreInstance = new FileStore();
}
return fileStoreInstance;
}
export { FileStore };

View file

@ -0,0 +1,269 @@
/**
* Image Processing Pipeline for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Handles image optimization, thumbnail generation, and EXIF extraction
*/
import sharp from 'sharp';
import {
ImageSize,
ThumbnailConfig,
THUMBNAIL_CONFIGS,
ExifData,
ImageProcessingOptions,
} from './types';
export interface ImageMetadata {
width: number;
height: number;
format: string;
space?: string;
channels?: number;
hasAlpha?: boolean;
orientation?: number;
}
export class ImageProcessor {
private defaultQuality = 85;
/**
* Get image metadata
*/
async getMetadata(buffer: Buffer): Promise<ImageMetadata> {
const metadata = await sharp(buffer).metadata();
return {
width: metadata.width || 0,
height: metadata.height || 0,
format: metadata.format || 'unknown',
space: metadata.space,
channels: metadata.channels,
hasAlpha: metadata.hasAlpha,
orientation: metadata.orientation,
};
}
/**
* Extract EXIF data from image
*/
async extractExif(buffer: Buffer): Promise<ExifData | undefined> {
try {
const metadata = await sharp(buffer).metadata();
if (!metadata.exif) {
return undefined;
}
// Parse basic EXIF data
const exifData: ExifData = {};
// Sharp provides some EXIF data directly
if (metadata.orientation) {
exifData.orientation = metadata.orientation;
}
// For more detailed EXIF parsing, we would need an EXIF library
// For now, return basic data
return Object.keys(exifData).length > 0 ? exifData : undefined;
} catch (error) {
console.warn('Error extracting EXIF data:', error);
return undefined;
}
}
/**
* Optimize an image for web
*/
async optimize(
buffer: Buffer,
options: ImageProcessingOptions = {}
): Promise<Buffer> {
const {
maxWidth = 2048,
maxHeight = 2048,
quality = this.defaultQuality,
convertToWebP = true,
} = options;
let pipeline = sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true,
});
if (convertToWebP) {
pipeline = pipeline.webp({ quality });
} else {
// Optimize in original format
const metadata = await sharp(buffer).metadata();
switch (metadata.format) {
case 'jpeg':
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
break;
case 'png':
pipeline = pipeline.png({ compressionLevel: 9 });
break;
case 'gif':
pipeline = pipeline.gif();
break;
default:
pipeline = pipeline.webp({ quality });
}
}
return pipeline.toBuffer();
}
/**
* Generate all thumbnail sizes
*/
async generateThumbnails(
buffer: Buffer,
sizes: ImageSize[] = ['thumbnail', 'small', 'medium', 'large']
): Promise<Record<string, Buffer>> {
const thumbnails: Record<string, Buffer> = {};
await Promise.all(
sizes.map(async (size) => {
if (size === 'original') return;
const config = THUMBNAIL_CONFIGS[size];
thumbnails[size] = await this.generateThumbnail(buffer, config);
})
);
return thumbnails;
}
/**
* Generate a single thumbnail
*/
async generateThumbnail(buffer: Buffer, config: ThumbnailConfig): Promise<Buffer> {
return sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(config.width, config.height, {
fit: config.fit,
withoutEnlargement: true,
})
.webp({ quality: 80 })
.toBuffer();
}
/**
* Convert image to WebP format
*/
async toWebP(buffer: Buffer, quality = 85): Promise<Buffer> {
return sharp(buffer)
.rotate()
.webp({ quality })
.toBuffer();
}
/**
* Crop image to specific dimensions
*/
async crop(
buffer: Buffer,
width: number,
height: number,
options: { left?: number; top?: number } = {}
): Promise<Buffer> {
const { left = 0, top = 0 } = options;
return sharp(buffer)
.extract({ left, top, width, height })
.toBuffer();
}
/**
* Smart crop using attention/entropy detection
*/
async smartCrop(buffer: Buffer, width: number, height: number): Promise<Buffer> {
return sharp(buffer)
.resize(width, height, {
fit: 'cover',
position: 'attention', // Focus on the most "interesting" part
})
.toBuffer();
}
/**
* Add a watermark to an image
*/
async addWatermark(
buffer: Buffer,
watermarkBuffer: Buffer,
options: {
position?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
opacity?: number;
} = {}
): Promise<Buffer> {
const { position = 'bottom-right', opacity = 0.5 } = options;
const image = sharp(buffer);
const metadata = await image.metadata();
const watermark = await sharp(watermarkBuffer)
.resize(Math.round((metadata.width || 500) * 0.2), null, {
withoutEnlargement: true,
})
.ensureAlpha()
.toBuffer();
const watermarkMeta = await sharp(watermark).metadata();
let gravity: sharp.Gravity;
switch (position) {
case 'top-left':
gravity = 'northwest';
break;
case 'top-right':
gravity = 'northeast';
break;
case 'bottom-left':
gravity = 'southwest';
break;
case 'bottom-right':
gravity = 'southeast';
break;
case 'center':
default:
gravity = 'center';
}
return image
.composite([
{
input: watermark,
gravity,
blend: 'over',
},
])
.toBuffer();
}
/**
* Generate a blur placeholder for progressive loading
*/
async generateBlurPlaceholder(buffer: Buffer, size = 10): Promise<string> {
const blurredBuffer = await sharp(buffer)
.resize(size, size, { fit: 'inside' })
.webp({ quality: 20 })
.toBuffer();
return `data:image/webp;base64,${blurredBuffer.toString('base64')}`;
}
/**
* Validate that buffer is a valid image
*/
async isValidImage(buffer: Buffer): Promise<boolean> {
try {
const metadata = await sharp(buffer).metadata();
return !!(metadata.width && metadata.height);
} catch {
return false;
}
}
}

34
lib/storage/index.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Storage Module for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Main entry point for file storage functionality
*/
// Types
export * from './types';
// Configuration
export { getStorageConfig, storageConfig } from './config';
// Services
export { getUploadService, UploadService } from './uploadService';
export { ImageProcessor } from './imageProcessor';
export { getFileStore, FileStore } from './fileStore';
// Providers
export { S3StorageProvider } from './providers/s3';
export { LocalStorageProvider } from './providers/local';
// Re-export commonly used types for convenience
export type {
FileMetadata,
FileCategory,
UploadOptions,
UploadResult,
PresignedUrlRequest,
PresignedUrlResponse,
ImageSize,
StorageProvider,
StorageConfig,
} from './types';

View file

@ -0,0 +1,131 @@
/**
* Local Storage Provider for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Filesystem-based storage for development and testing
*/
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
import { StorageConfig, StorageProviderInterface } from '../types';
export class LocalStorageProvider implements StorageProviderInterface {
private basePath: string;
private publicUrl: string;
constructor(config: StorageConfig) {
this.basePath = config.localPath || './uploads';
this.publicUrl = config.publicUrl || 'http://localhost:3001';
}
private getFilePath(key: string): string {
return path.join(this.basePath, key);
}
private async ensureDirectory(filePath: string): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
}
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
const filePath = this.getFilePath(key);
await this.ensureDirectory(filePath);
await fs.writeFile(filePath, buffer);
// Write metadata file
const metadataPath = `${filePath}.meta.json`;
await fs.writeFile(
metadataPath,
JSON.stringify({
contentType,
size: buffer.length,
uploadedAt: new Date().toISOString(),
})
);
return this.getPublicUrl(key);
}
async delete(key: string): Promise<boolean> {
try {
const filePath = this.getFilePath(key);
await fs.unlink(filePath);
// Also delete metadata file if exists
try {
await fs.unlink(`${filePath}.meta.json`);
} catch {
// Metadata file may not exist
}
return true;
} catch (error) {
console.error('Error deleting local file:', error);
return false;
}
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
// For local storage, we generate a signed URL using HMAC
const expires = Date.now() + expiresIn * 1000;
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
const signature = crypto
.createHmac('sha256', secret)
.update(`${key}:${expires}`)
.digest('hex')
.substring(0, 16);
return `${this.publicUrl}/api/upload/${encodeURIComponent(key)}?expires=${expires}&sig=${signature}`;
}
async getPresignedUploadUrl(key: string, contentType: string, expiresIn = 3600): Promise<string> {
// For local storage, return an API endpoint for upload
const expires = Date.now() + expiresIn * 1000;
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
const signature = crypto
.createHmac('sha256', secret)
.update(`upload:${key}:${contentType}:${expires}`)
.digest('hex')
.substring(0, 16);
return `${this.publicUrl}/api/upload/presigned?key=${encodeURIComponent(key)}&contentType=${encodeURIComponent(contentType)}&expires=${expires}&sig=${signature}`;
}
async exists(key: string): Promise<boolean> {
try {
await fs.access(this.getFilePath(key));
return true;
} catch {
return false;
}
}
getPublicUrl(key: string): string {
return `${this.publicUrl}/uploads/${key}`;
}
/**
* Verify a signed URL signature
*/
static verifySignature(
key: string,
expires: number,
signature: string,
prefix = ''
): boolean {
if (Date.now() > expires) {
return false;
}
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(`${prefix}${key}:${expires}`)
.digest('hex')
.substring(0, 16);
return signature === expectedSignature;
}
}

115
lib/storage/providers/s3.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* S3 Storage Provider for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Compatible with AWS S3, Cloudflare R2, and MinIO
*/
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { StorageConfig, StorageProviderInterface } from '../types';
export class S3StorageProvider implements StorageProviderInterface {
private client: S3Client;
private bucket: string;
private publicUrl?: string;
constructor(config: StorageConfig) {
this.bucket = config.bucket;
this.publicUrl = config.publicUrl;
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
region: config.region || 'us-east-1',
};
// Add endpoint for R2/MinIO
if (config.endpoint) {
clientConfig.endpoint = config.endpoint;
clientConfig.forcePathStyle = config.provider === 'minio';
}
// Add credentials if provided
if (config.accessKeyId && config.secretAccessKey) {
clientConfig.credentials = {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
};
}
this.client = new S3Client(clientConfig);
}
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: 'public, max-age=31536000', // 1 year cache
});
await this.client.send(command);
return this.getPublicUrl(key);
}
async delete(key: string): Promise<boolean> {
try {
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
});
await this.client.send(command);
return true;
} catch (error) {
console.error('Error deleting file from S3:', error);
return false;
}
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.client, command, { expiresIn });
}
async getPresignedUploadUrl(key: string, contentType: string, expiresIn = 3600): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
ContentType: contentType,
});
return getSignedUrl(this.client, command, { expiresIn });
}
async exists(key: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
Bucket: this.bucket,
Key: key,
});
await this.client.send(command);
return true;
} catch {
return false;
}
}
getPublicUrl(key: string): string {
if (this.publicUrl) {
return `${this.publicUrl}/${key}`;
}
return `https://${this.bucket}.s3.amazonaws.com/${key}`;
}
}

133
lib/storage/types.ts Normal file
View file

@ -0,0 +1,133 @@
/**
* File Storage Types for LocalGreenChain
* Agent 3: File Upload & Storage System
*/
export type StorageProvider = 's3' | 'r2' | 'minio' | 'local';
export type FileCategory = 'plant-photo' | 'certificate' | 'document' | 'report' | 'avatar';
export type ImageSize = 'thumbnail' | 'small' | 'medium' | 'large' | 'original';
export interface StorageConfig {
provider: StorageProvider;
bucket: string;
region?: string;
endpoint?: string;
accessKeyId?: string;
secretAccessKey?: string;
publicUrl?: string;
localPath?: string;
}
export interface FileMetadata {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
category: FileCategory;
uploadedBy?: string;
plantId?: string;
farmId?: string;
url: string;
thumbnailUrl?: string;
urls?: Record<ImageSize, string>;
width?: number;
height?: number;
exifData?: ExifData;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
export interface ExifData {
make?: string;
model?: string;
dateTaken?: Date;
gpsLatitude?: number;
gpsLongitude?: number;
orientation?: number;
}
export interface UploadOptions {
category: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
generateThumbnails?: boolean;
maxSizeBytes?: number;
allowedMimeTypes?: string[];
}
export interface UploadResult {
success: boolean;
file?: FileMetadata;
error?: string;
presignedUrl?: string;
}
export interface PresignedUrlRequest {
filename: string;
contentType: string;
category: FileCategory;
expiresIn?: number;
}
export interface PresignedUrlResponse {
uploadUrl: string;
fileKey: string;
publicUrl: string;
expiresAt: Date;
}
export interface ImageProcessingOptions {
generateThumbnails?: boolean;
convertToWebP?: boolean;
extractExif?: boolean;
maxWidth?: number;
maxHeight?: number;
quality?: number;
}
export interface ThumbnailConfig {
size: ImageSize;
width: number;
height: number;
fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
}
export const THUMBNAIL_CONFIGS: Record<ImageSize, ThumbnailConfig> = {
thumbnail: { size: 'thumbnail', width: 150, height: 150, fit: 'cover' },
small: { size: 'small', width: 300, height: 300, fit: 'inside' },
medium: { size: 'medium', width: 600, height: 600, fit: 'inside' },
large: { size: 'large', width: 1200, height: 1200, fit: 'inside' },
original: { size: 'original', width: 0, height: 0, fit: 'inside' },
};
export const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/heic',
'image/heif',
];
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export const DEFAULT_MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
export interface StorageProviderInterface {
upload(key: string, buffer: Buffer, contentType: string): Promise<string>;
delete(key: string): Promise<boolean>;
getSignedUrl(key: string, expiresIn?: number): Promise<string>;
getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string>;
exists(key: string): Promise<boolean>;
getPublicUrl(key: string): string;
}

View file

@ -0,0 +1,270 @@
/**
* Upload Service for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Core upload functionality with validation and processing
*/
import { v4 as uuidv4 } from 'crypto';
import {
FileMetadata,
FileCategory,
UploadOptions,
UploadResult,
PresignedUrlRequest,
PresignedUrlResponse,
StorageProviderInterface,
ALLOWED_IMAGE_TYPES,
ALLOWED_DOCUMENT_TYPES,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_IMAGE_SIZE,
ImageSize,
} from './types';
import { getStorageConfig, storageConfig } from './config';
import { S3StorageProvider } from './providers/s3';
import { LocalStorageProvider } from './providers/local';
import { ImageProcessor } from './imageProcessor';
// Generate a UUID v4
function generateId(): string {
const bytes = require('crypto').randomBytes(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
return bytes.toString('hex').replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
}
class UploadService {
private provider: StorageProviderInterface;
private imageProcessor: ImageProcessor;
constructor() {
const config = getStorageConfig();
switch (config.provider) {
case 's3':
case 'r2':
case 'minio':
this.provider = new S3StorageProvider(config);
break;
case 'local':
default:
this.provider = new LocalStorageProvider(config);
break;
}
this.imageProcessor = new ImageProcessor();
}
/**
* Generate a unique file key with path
*/
private generateFileKey(filename: string, category: FileCategory): string {
const id = generateId();
const ext = filename.split('.').pop()?.toLowerCase() || 'bin';
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${category}/${year}/${month}/${id}.${ext}`;
}
/**
* Validate file type and size
*/
private validateFile(
buffer: Buffer,
mimeType: string,
options: UploadOptions
): { valid: boolean; error?: string } {
const isImage = ALLOWED_IMAGE_TYPES.includes(mimeType);
const isDocument = ALLOWED_DOCUMENT_TYPES.includes(mimeType);
// Check allowed types based on category
if (options.category === 'plant-photo' || options.category === 'avatar') {
if (!isImage) {
return { valid: false, error: 'Only image files are allowed for this category' };
}
}
if (options.category === 'document' || options.category === 'certificate') {
if (!isDocument && !isImage) {
return { valid: false, error: 'Only documents and images are allowed for this category' };
}
}
// Check custom allowed types
if (options.allowedMimeTypes && options.allowedMimeTypes.length > 0) {
if (!options.allowedMimeTypes.includes(mimeType)) {
return { valid: false, error: `File type ${mimeType} is not allowed` };
}
}
// Check file size
const maxSize = options.maxSizeBytes ||
(isImage ? DEFAULT_MAX_IMAGE_SIZE : DEFAULT_MAX_FILE_SIZE);
if (buffer.length > maxSize) {
const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1);
return { valid: false, error: `File size exceeds maximum of ${maxSizeMB}MB` };
}
return { valid: true };
}
/**
* Upload a file with processing
*/
async upload(
buffer: Buffer,
originalName: string,
mimeType: string,
options: UploadOptions
): Promise<UploadResult> {
// Validate file
const validation = this.validateFile(buffer, mimeType, options);
if (!validation.valid) {
return { success: false, error: validation.error };
}
const isImage = ALLOWED_IMAGE_TYPES.includes(mimeType);
const fileKey = this.generateFileKey(originalName, options.category);
const fileId = generateId();
try {
let processedBuffer = buffer;
let width: number | undefined;
let height: number | undefined;
let exifData: FileMetadata['exifData'];
const urls: Partial<Record<ImageSize, string>> = {};
// Process images
if (isImage && options.generateThumbnails !== false) {
// Get image metadata
const metadata = await this.imageProcessor.getMetadata(buffer);
width = metadata.width;
height = metadata.height;
// Extract EXIF data
exifData = await this.imageProcessor.extractExif(buffer);
// Optimize original image
const optimized = await this.imageProcessor.optimize(buffer);
processedBuffer = optimized;
// Generate thumbnails
const thumbnails = await this.imageProcessor.generateThumbnails(buffer);
// Upload thumbnails
for (const [size, thumbBuffer] of Object.entries(thumbnails)) {
const thumbKey = fileKey.replace(/\.[^.]+$/, `-${size}.webp`);
urls[size as ImageSize] = await this.provider.upload(thumbKey, thumbBuffer, 'image/webp');
}
}
// Upload main file
const finalMimeType = isImage ? 'image/webp' : mimeType;
const finalKey = isImage ? fileKey.replace(/\.[^.]+$/, '.webp') : fileKey;
const mainUrl = await this.provider.upload(finalKey, processedBuffer, finalMimeType);
urls.original = mainUrl;
// Create file metadata
const fileMetadata: FileMetadata = {
id: fileId,
filename: finalKey,
originalName,
mimeType: finalMimeType,
size: processedBuffer.length,
category: options.category,
uploadedBy: options.userId,
plantId: options.plantId,
farmId: options.farmId,
url: mainUrl,
thumbnailUrl: urls.thumbnail,
urls: urls as Record<ImageSize, string>,
width,
height,
exifData,
createdAt: new Date(),
updatedAt: new Date(),
};
return { success: true, file: fileMetadata };
} catch (error) {
console.error('Upload error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
};
}
}
/**
* Generate a presigned URL for direct upload
*/
async getPresignedUploadUrl(request: PresignedUrlRequest): Promise<PresignedUrlResponse> {
const fileKey = this.generateFileKey(request.filename, request.category);
const expiresIn = request.expiresIn || 3600; // 1 hour default
const uploadUrl = await this.provider.getPresignedUploadUrl(
fileKey,
request.contentType,
expiresIn
);
return {
uploadUrl,
fileKey,
publicUrl: this.provider.getPublicUrl(fileKey),
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
/**
* Delete a file and its thumbnails
*/
async delete(fileKey: string): Promise<boolean> {
try {
// Delete main file
await this.provider.delete(fileKey);
// Delete thumbnails if they exist
const sizes: ImageSize[] = ['thumbnail', 'small', 'medium', 'large'];
for (const size of sizes) {
const thumbKey = fileKey.replace(/\.[^.]+$/, `-${size}.webp`);
await this.provider.delete(thumbKey);
}
return true;
} catch (error) {
console.error('Delete error:', error);
return false;
}
}
/**
* Get a signed URL for private file access
*/
async getSignedUrl(fileKey: string, expiresIn = 3600): Promise<string> {
return this.provider.getSignedUrl(fileKey, expiresIn);
}
/**
* Check if a file exists
*/
async exists(fileKey: string): Promise<boolean> {
return this.provider.exists(fileKey);
}
}
// Singleton instance
let uploadServiceInstance: UploadService | null = null;
export function getUploadService(): UploadService {
if (!uploadServiceInstance) {
uploadServiceInstance = new UploadService();
}
return uploadServiceInstance;
}
export { UploadService };

View file

@ -31,6 +31,8 @@
"validate": "bun run type-check && bun run lint && bun run test" "validate": "bun run type-check && bun run lint && bun run test"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/s3-request-presigner": "^3.937.0",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.7.0", "@prisma/client": "^5.7.0",
"@tailwindcss/forms": "^0.4.0", "@tailwindcss/forms": "^0.4.0",
@ -40,6 +42,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"drupal-jsonapi-params": "^1.2.2", "drupal-jsonapi-params": "^1.2.2",
"html-react-parser": "^1.2.7", "html-react-parser": "^1.2.7",
"multer": "^2.0.2",
"next": "^12.2.3", "next": "^12.2.3",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"next-drupal": "^1.6.0", "next-drupal": "^1.6.0",
@ -47,6 +50,7 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-hook-form": "^7.8.6", "react-hook-form": "^7.8.6",
"sharp": "^0.34.5",
"socks-proxy-agent": "^8.0.2" "socks-proxy-agent": "^8.0.2"
}, },
"devDependencies": { "devDependencies": {
@ -55,8 +59,10 @@
"@commitlint/config-conventional": "^18.4.3", "@commitlint/config-conventional": "^18.4.3",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/jest": "^29.5.0", "@types/jest": "^29.5.0",
"@types/multer": "^2.0.0",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/sharp": "^0.32.0",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"cypress": "^13.6.0", "cypress": "^13.6.0",
"eslint-config-next": "^12.0.10", "eslint-config-next": "^12.0.10",

View file

@ -0,0 +1,99 @@
/**
* File Management API Endpoint
* Agent 3: File Upload & Storage System
*
* GET /api/upload/[fileId] - Get file info or signed URL
* DELETE /api/upload/[fileId] - Delete a file
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
interface FileResponse {
success: boolean;
signedUrl?: string;
exists?: boolean;
error?: string;
}
interface DeleteResponse {
success: boolean;
error?: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<FileResponse | DeleteResponse>
) {
const { fileId } = req.query;
if (!fileId || typeof fileId !== 'string') {
return res.status(400).json({ success: false, error: 'File ID is required' });
}
// Decode the file key (it may be URL encoded)
const fileKey = decodeURIComponent(fileId);
const uploadService = getUploadService();
switch (req.method) {
case 'GET': {
try {
// Check if file exists
const exists = await uploadService.exists(fileKey);
if (!exists) {
return res.status(404).json({
success: false,
exists: false,
error: 'File not found',
});
}
// Get expiration time from query params (default 1 hour)
const expiresIn = parseInt(req.query.expiresIn as string) || 3600;
// Get signed URL for private file access
const signedUrl = await uploadService.getSignedUrl(fileKey, expiresIn);
return res.status(200).json({
success: true,
exists: true,
signedUrl,
});
} catch (error) {
console.error('Get file error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}
case 'DELETE': {
try {
const deleted = await uploadService.delete(fileKey);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'File not found or could not be deleted',
});
}
return res.status(200).json({
success: true,
});
} catch (error) {
console.error('Delete file error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}
default:
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
}

View file

@ -0,0 +1,160 @@
/**
* 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',
});
}
}

153
pages/api/upload/image.ts Normal file
View file

@ -0,0 +1,153 @@
/**
* Image Upload API Endpoint
* Agent 3: File Upload & Storage System
*
* POST /api/upload/image - Upload an image file
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
import type { FileCategory, UploadResult } 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;
thumbnailUrl?: string;
urls?: Record<string, string>;
width?: number;
height?: number;
size: number;
mimeType: 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 = 'upload';
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) {
// This is a file
filename = filenameMatch[1];
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (contentTypeMatch) {
mimeType = contentTypeMatch[1].trim();
}
// Extract file content (after double CRLF)
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) {
// This is a regular field
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);
const category = (fields.category as FileCategory) || 'plant-photo';
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: true,
});
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,
thumbnailUrl: result.file.thumbnailUrl,
urls: result.file.urls,
width: result.file.width,
height: result.file.height,
size: result.file.size,
mimeType: result.file.mimeType,
},
});
} catch (error) {
console.error('Upload error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}

View file

@ -0,0 +1,92 @@
/**
* Presigned URL API Endpoint
* Agent 3: File Upload & Storage System
*
* POST /api/upload/presigned - Get a presigned URL for direct upload
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
import type { FileCategory, PresignedUrlResponse } from '../../../lib/storage/types';
interface RequestBody {
filename: string;
contentType: string;
category: FileCategory;
expiresIn?: number;
}
interface PresignedResponse {
success: boolean;
data?: PresignedUrlResponse;
error?: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<PresignedResponse>
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const { filename, contentType, category, expiresIn } = req.body as RequestBody;
// Validate required fields
if (!filename || !contentType || !category) {
return res.status(400).json({
success: false,
error: 'Missing required fields: filename, contentType, category',
});
}
// Validate category
const validCategories: FileCategory[] = ['plant-photo', 'certificate', 'document', 'report', 'avatar'];
if (!validCategories.includes(category)) {
return res.status(400).json({
success: false,
error: `Invalid category. Must be one of: ${validCategories.join(', ')}`,
});
}
// Validate content type
const allowedContentTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/heic',
'image/heif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (!allowedContentTypes.includes(contentType)) {
return res.status(400).json({
success: false,
error: `Invalid content type. Must be one of: ${allowedContentTypes.join(', ')}`,
});
}
const uploadService = getUploadService();
const presignedUrl = await uploadService.getPresignedUploadUrl({
filename,
contentType,
category,
expiresIn: expiresIn || 3600, // Default 1 hour
});
return res.status(200).json({
success: true,
data: presignedUrl,
});
} catch (error) {
console.error('Presigned URL error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}