localgreenchain/lib/storage/fileStore.ts
Claude d74128d3cd
Add Agent 3: File Upload & Storage System
Implements cloud-based file storage for plant photos, documents, and certificates:

Storage Layer:
- Multi-provider support (AWS S3, Cloudflare R2, MinIO, local filesystem)
- S3-compatible provider with presigned URL generation
- Local storage provider for development with signed URL verification
- Configurable via environment variables

Image Processing:
- Automatic thumbnail generation (150x150, 300x300, 600x600, 1200x1200)
- WebP conversion for optimized file sizes
- EXIF data extraction for image metadata
- Image optimization with Sharp

API Endpoints:
- POST /api/upload/image - Upload images with automatic processing
- POST /api/upload/document - Upload documents (PDF, DOC, DOCX)
- POST /api/upload/presigned - Get presigned URLs for direct uploads
- GET/DELETE /api/upload/[fileId] - File management

UI Components:
- ImageUploader - Drag & drop image upload with preview
- PhotoGallery - Grid gallery with lightbox view
- DocumentUploader - Document upload with file type icons
- ProgressBar - Animated upload progress indicator

Database:
- FileStore service with in-memory storage (Prisma schema ready for Agent 2)
- File metadata tracking with soft delete support
- Category-based file organization
2025-11-23 03:51:31 +00:00

258 lines
6.6 KiB
TypeScript

/**
* 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 };