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
258 lines
6.6 KiB
TypeScript
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 };
|