Merge: File Upload & Storage System (Agent 3) - resolved conflicts
This commit is contained in:
commit
b0dc9fca4d
19 changed files with 2918 additions and 196 deletions
236
components/upload/DocumentUploader.tsx
Normal file
236
components/upload/DocumentUploader.tsx
Normal 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;
|
||||||
266
components/upload/ImageUploader.tsx
Normal file
266
components/upload/ImageUploader.tsx
Normal 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;
|
||||||
213
components/upload/PhotoGallery.tsx
Normal file
213
components/upload/PhotoGallery.tsx
Normal 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;
|
||||||
63
components/upload/ProgressBar.tsx
Normal file
63
components/upload/ProgressBar.tsx
Normal 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;
|
||||||
11
components/upload/index.tsx
Normal file
11
components/upload/index.tsx
Normal 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
87
lib/storage/config.ts
Normal 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
258
lib/storage/fileStore.ts
Normal 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 };
|
||||||
269
lib/storage/imageProcessor.ts
Normal file
269
lib/storage/imageProcessor.ts
Normal 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
34
lib/storage/index.ts
Normal 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';
|
||||||
131
lib/storage/providers/local.ts
Normal file
131
lib/storage/providers/local.ts
Normal 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
115
lib/storage/providers/s3.ts
Normal 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
133
lib/storage/types.ts
Normal 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;
|
||||||
|
}
|
||||||
270
lib/storage/uploadService.ts
Normal file
270
lib/storage/uploadService.ts
Normal 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 };
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
99
pages/api/upload/[fileId].ts
Normal file
99
pages/api/upload/[fileId].ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
160
pages/api/upload/document.ts
Normal file
160
pages/api/upload/document.ts
Normal 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
153
pages/api/upload/image.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
92
pages/api/upload/presigned.ts
Normal file
92
pages/api/upload/presigned.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue