# Photo Management & Storage Optimization **Priority**: 🔴 Critical (Required for Phase 2) **Team**: 777 Wolfpack **Date**: 2025-12-09 **Status**: Technical Spec --- ## 📋 Overview Optimize photo storage for daily walkthroughs and plant touch points to minimize storage costs and bandwidth usage while maintaining quality for compliance and documentation. **Key Constraint**: Photos will be taken frequently (multiple times per day, per user) and need to be stored long-term for compliance. --- ## 🎯 Storage Strategy ### Option 1: Local Storage with Compression (Recommended) **Pros**: - No external service costs - Full control over data - HIPAA/compliance friendly - Fast access **Cons**: - Need to manage disk space - Need backup strategy ### Option 2: S3-Compatible Storage (Future) **Pros**: - Scalable - Built-in redundancy - CDN integration **Cons**: - Monthly costs - External dependency **Recommendation**: Start with **Option 1** (local storage), migrate to **Option 2** when needed. --- ## 📸 Image Optimization Strategy ### 1. Client-Side Compression (Before Upload) **Technology**: Browser Canvas API or `browser-image-compression` library **Settings**: - **Max Width**: 1920px (sufficient for compliance) - **Max Height**: 1920px - **Quality**: 0.8 (80% - good balance) - **Format**: WebP (30-50% smaller than JPEG) - **Fallback**: JPEG for older browsers **Expected Savings**: - Original: ~3-5 MB per photo - Compressed: ~200-500 KB per photo - **Reduction**: 85-90% ### 2. Server-Side Processing **Technology**: Sharp (Node.js image processing) **Pipeline**: 1. Receive uploaded image 2. Generate multiple sizes: - **Thumbnail**: 200x200px (for lists/previews) - **Medium**: 800x800px (for detail views) - **Full**: 1920x1920px (for compliance/download) 3. Convert to WebP 4. Store all versions **Storage Per Photo**: - Thumbnail: ~10-20 KB - Medium: ~50-100 KB - Full: ~200-500 KB - **Total**: ~300-700 KB (vs 3-5 MB original) --- ## 🗄️ Storage Structure ### File System Organization ``` /srv/storage/ca-grow-ops-manager/ ├── photos/ │ ├── walkthroughs/ │ │ ├── 2025/ │ │ │ ├── 12/ │ │ │ │ ├── 09/ │ │ │ │ │ ├── {walkthrough-id}/ │ │ │ │ │ │ ├── reservoir/ │ │ │ │ │ │ │ ├── {photo-id}_thumb.webp │ │ │ │ │ │ │ ├── {photo-id}_medium.webp │ │ │ │ │ │ │ └── {photo-id}_full.webp │ │ │ │ │ │ ├── irrigation/ │ │ │ │ │ │ └── plant-health/ │ ├── touch-points/ │ │ ├── 2025/ │ │ │ ├── 12/ │ │ │ │ └── ... └── backups/ └── ... ``` ### Database Storage (URLs Only) ```typescript // Store relative paths, not full URLs photoUrl: "/photos/walkthroughs/2025/12/09/{walkthrough-id}/reservoir/{photo-id}_full.webp" thumbnailUrl: "/photos/walkthroughs/2025/12/09/{walkthrough-id}/reservoir/{photo-id}_thumb.webp" ``` --- ## 🔧 Implementation ### Backend: Photo Upload Endpoint ```typescript // backend/src/routes/upload.routes.ts import { FastifyInstance } from 'fastify'; import multipart from '@fastify/multipart'; import sharp from 'sharp'; import { promises as fs } from 'fs'; import path from 'path'; export async function uploadRoutes(server: FastifyInstance) { server.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, // 10MB max (before compression) }, }); server.post('/upload/photo', async (request, reply) => { const data = await request.file(); if (!data) { return reply.code(400).send({ message: 'No file uploaded' }); } const buffer = await data.toBuffer(); const photoId = generatePhotoId(); const date = new Date(); const basePath = `/srv/storage/ca-grow-ops-manager/photos/walkthroughs/${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; // Ensure directory exists await fs.mkdir(basePath, { recursive: true }); // Generate 3 sizes const sizes = [ { name: 'thumb', width: 200, height: 200 }, { name: 'medium', width: 800, height: 800 }, { name: 'full', width: 1920, height: 1920 }, ]; const urls: Record = {}; for (const size of sizes) { const filename = `${photoId}_${size.name}.webp`; const filepath = path.join(basePath, filename); await sharp(buffer) .resize(size.width, size.height, { fit: 'inside', withoutEnlargement: true, }) .webp({ quality: 80 }) .toFile(filepath); urls[size.name] = `/photos/walkthroughs/${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}/${filename}`; } return { photoId, urls, }; }); } function generatePhotoId(): string { return `photo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } ``` ### Frontend: Photo Compression ```typescript // frontend/src/lib/photoCompression.ts import imageCompression from 'browser-image-compression'; export async function compressPhoto(file: File): Promise { const options = { maxSizeMB: 1, // Max 1MB maxWidthOrHeight: 1920, useWebWorker: true, fileType: 'image/webp', }; try { const compressedFile = await imageCompression(file, options); return compressedFile; } catch (error) { console.error('Photo compression failed:', error); return file; // Return original if compression fails } } // Usage in component async function handlePhotoCapture(event: React.ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; setIsUploading(true); // Compress before upload const compressedFile = await compressPhoto(file); // Upload const formData = new FormData(); formData.append('file', compressedFile); const response = await api.post('/upload/photo', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setPhotoUrl(response.data.urls.full); setIsUploading(false); } ``` --- ## 📊 Storage Estimates ### Daily Usage (777 Wolfpack) **Assumptions**: - 3 staff members - 1 walkthrough per day per person - Average 5 photos per walkthrough - 365 days per year **Calculations**: - Photos per day: 3 × 5 = 15 photos - Storage per photo: ~500 KB (compressed) - Daily storage: 15 × 500 KB = 7.5 MB/day - Monthly storage: 7.5 MB × 30 = 225 MB/month - Yearly storage: 7.5 MB × 365 = 2.7 GB/year **With Touch Points** (Phase 2): - Assume 10 touch points per day per person - 2 photos per touch point - Additional photos per day: 3 × 10 × 2 = 60 photos - Additional storage: 60 × 500 KB = 30 MB/day - **Total daily**: 37.5 MB/day - **Total yearly**: ~13.7 GB/year **5-Year Projection**: ~68 GB (very manageable) --- ## 💾 Backup Strategy ### Local Backups **Frequency**: Daily at 2 AM **Method**: rsync to backup directory ```bash rsync -av --delete \ /srv/storage/ca-grow-ops-manager/photos/ \ /srv/backups/ca-grow-ops-manager/photos/ ``` ### Off-Site Backups (Recommended) **Frequency**: Weekly **Options**: 1. **Restic** to Backblaze B2 (~$0.005/GB/month) 2. **Rclone** to any S3-compatible storage 3. **Tarsnap** for encrypted backups **Cost Estimate** (Backblaze B2): - 70 GB × $0.005 = $0.35/month (5-year data) - Very affordable --- ## 🔒 Security & Compliance ### Access Control - Photos only accessible to authenticated users - Serve via backend proxy (not direct file access) - Check user permissions before serving ### Retention Policy - Keep all photos for 7 years (CA compliance) - Automatic archival after 2 years (move to cold storage) - Deletion only after retention period ### Privacy - No EXIF data stored (stripped during processing) - No GPS coordinates - Timestamps preserved for audit trail --- ## 📦 Dependencies ### Backend ```json { "dependencies": { "@fastify/multipart": "^8.0.0", "sharp": "^0.33.0" } } ``` ### Frontend ```json { "dependencies": { "browser-image-compression": "^2.0.2" } } ``` --- ## ✅ Implementation Checklist ### Phase 1: Basic Upload (1-2 hours) - [ ] Install dependencies (sharp, multipart) - [ ] Create upload endpoint - [ ] Create storage directory structure - [ ] Test upload with Postman ### Phase 2: Compression (2-3 hours) - [ ] Implement server-side compression (3 sizes) - [ ] Add client-side compression - [ ] Test compression quality - [ ] Measure storage savings ### Phase 3: Integration (2-3 hours) - [ ] Add photo upload to walkthrough UI - [ ] Add photo upload to touch points UI - [ ] Display thumbnails in lists - [ ] Display full images in detail views ### Phase 4: Optimization (1-2 hours) - [ ] Add lazy loading for images - [ ] Implement progressive image loading - [ ] Add image caching headers - [ ] Test on slow connections ### Phase 5: Backup (1 hour) - [ ] Set up daily local backups - [ ] Set up weekly off-site backups - [ ] Test restore process **Total Estimated Time**: 7-11 hours --- ## 🎯 Success Metrics ### Storage Efficiency - Target: < 500 KB per photo (compressed) - Target: < 50 MB per day (all photos) - Target: < 20 GB per year (all photos) ### Performance - Upload time: < 5 seconds per photo - Display time: < 1 second (thumbnail) - Compression time: < 2 seconds client-side ### Reliability - 99.9% uptime for photo access - Zero data loss - Successful daily backups --- ## 💡 Future Enhancements ### Advanced Features - [ ] AI-powered image tagging - [ ] Automatic pest detection from photos - [ ] Image similarity search - [ ] Timelapse generation from touch point photos - [ ] OCR for reading labels/tags in photos ### Storage Optimization - [ ] Migrate old photos to cold storage - [ ] Implement CDN for faster access - [ ] Add image deduplication - [ ] Compress old photos further (lower quality) --- ## 📝 Notes ### WebP Support - Supported by all modern browsers (95%+ coverage) - Fallback to JPEG for older browsers - 30-50% smaller than JPEG at same quality ### Sharp vs Other Libraries - **Sharp**: Fastest, best quality, recommended - **ImageMagick**: Slower, more features - **Jimp**: Pure JS, slower, no native deps **Recommendation**: Use Sharp for production --- **Status**: ✅ Spec Complete - Ready for Implementation **Priority**: Implement before enabling photo uploads in production **Estimated Effort**: 7-11 hours