From f91fbc2237cd5155ae1be5696cad41011d77ab8b Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:00:22 -0800 Subject: [PATCH] feat: add Reseed Demo Plants button to DevTools (realistic plant layout) --- backend/src/routes/admin.routes.ts | 165 +++++++++++++++++++++++ backend/src/server.ts | 4 + frontend/src/components/dev/DevTools.tsx | 47 ++++++- 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/admin.routes.ts diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts new file mode 100644 index 0000000..192dd72 --- /dev/null +++ b/backend/src/routes/admin.routes.ts @@ -0,0 +1,165 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +const DEMO_PREFIX = '[DEMO]'; + +/** + * Admin routes for demo/testing operations + * These should only be available in development or testing environments + */ +export async function adminRoutes(fastify: FastifyInstance) { + // Auth check - require OWNER or ADMIN role + fastify.addHook('onRequest', async (request, reply) => { + try { + await request.jwtVerify(); + const user = request.user as { role?: string }; + if (!['OWNER', 'ADMIN'].includes(user?.role || '')) { + return reply.code(403).send({ error: 'Admin access required' }); + } + } catch (err) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + }); + + /** + * Reseed demo plants in a realistic layout + * - Each batch stays in appropriate room based on stage + * - Veg batches → Veg rooms, Flower batches → Flower rooms, etc. + */ + fastify.post('/reseed-plants', async (request: FastifyRequest, reply: FastifyReply) => { + const prisma = fastify.prisma; + + try { + fastify.log.info('🌿 Reseeding plants in realistic layout...'); + + // Clear existing plants + await prisma.facilityPlant.deleteMany({}); + await prisma.facilityPosition.updateMany({ + data: { status: 'EMPTY' } + }); + + // Get all demo batches + const batches = await prisma.batch.findMany({ + where: { name: { startsWith: DEMO_PREFIX } }, + orderBy: { startDate: 'asc' } + }); + + if (batches.length === 0) { + return reply.code(400).send({ + error: 'No demo batches found. Run seed-demo.js first.' + }); + } + + // Section mapping by room type + const sectionMapping: Record = { + VEG_PRIMARY: ['T1', 'T2', 'T3', 'T4'], + MOTHER: ['M1', 'M2'], + FLOWER_A: ['A1', 'A2', 'A3', 'A4', 'A5', 'A6'], + FLOWER_B: ['B1', 'B2', 'B3', 'B4', 'B5', 'B6'], + DRY: ['D1', 'D2'], + CURE: ['C1', 'C2'], + }; + + const results: { batch: string; planted: number }[] = []; + + for (const batch of batches) { + let targetSections: string[] = []; + let fillPercentage = 0.85; + + switch (batch.stage) { + case 'CLONE_IN': + case 'VEGETATIVE': + targetSections = sectionMapping.VEG_PRIMARY; + fillPercentage = 0.9; + break; + case 'FLOWERING': + if (batch.strain === 'Gorilla Glue #4') { + targetSections = sectionMapping.FLOWER_A; + } else if (batch.strain === 'Wedding Cake') { + targetSections = sectionMapping.FLOWER_B; + } else { + targetSections = ['A4', 'A5', 'A6']; + } + fillPercentage = 0.95; + break; + case 'DRYING': + targetSections = sectionMapping.DRY; + fillPercentage = 0.6; + break; + case 'CURING': + targetSections = sectionMapping.CURE; + fillPercentage = 0.5; + break; + default: + continue; + } + + // Get sections + const sections = await prisma.facilitySection.findMany({ + where: { code: { in: targetSections } }, + include: { positions: true } + }); + + // Collect available positions + let allPositions: any[] = []; + for (const section of sections) { + const available = section.positions.filter((p: any) => p.status === 'EMPTY'); + allPositions = allPositions.concat( + available.map((p: any) => ({ ...p, sectionCode: section.code })) + ); + } + + const targetCount = Math.min( + Math.floor(allPositions.length * fillPercentage), + batch.plantCount || allPositions.length + ); + + const plantsToCreate = []; + const positionIds = []; + + for (let i = 0; i < targetCount && i < allPositions.length; i++) { + const pos = allPositions[i]; + const tagPrefix = batch.strain?.substring(0, 2).toUpperCase() || 'XX'; + const tagNumber = `1A4-${tagPrefix}-${batch.id.substring(0, 4).toUpperCase()}-${String(i + 1).padStart(4, '0')}`; + + plantsToCreate.push({ + tagNumber, + batchId: batch.id, + positionId: pos.id, + address: `${pos.sectionCode}-R${pos.row}-C${pos.column}`, + status: 'ACTIVE' + }); + positionIds.push(pos.id); + } + + if (plantsToCreate.length > 0) { + await prisma.facilityPlant.createMany({ + data: plantsToCreate, + skipDuplicates: true + }); + await prisma.facilityPosition.updateMany({ + where: { id: { in: positionIds } }, + data: { status: 'OCCUPIED' } + }); + } + + results.push({ batch: batch.name || batch.id, planted: plantsToCreate.length }); + } + + const totalPlants = await prisma.facilityPlant.count(); + + return { + success: true, + message: 'Plants reseeded in realistic layout', + totalPlants, + batches: results + }; + + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ + error: 'Failed to reseed plants', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index e0181b8..cd5fa1d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -75,6 +75,10 @@ server.register(financialRoutes, { prefix: '/api/financial' }); server.register(insightsRoutes, { prefix: '/api/insights' }); server.register(uploadRoutes, { prefix: '/api/upload' }); +// Admin routes (demo/testing) +import { adminRoutes } from './routes/admin.routes'; +server.register(adminRoutes, { prefix: '/api/admin' }); + server.get('/api/healthz', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); diff --git a/frontend/src/components/dev/DevTools.tsx b/frontend/src/components/dev/DevTools.tsx index eb3c81e..924a0be 100644 --- a/frontend/src/components/dev/DevTools.tsx +++ b/frontend/src/components/dev/DevTools.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; -import { Bug, ChevronUp, ChevronDown, User, X } from 'lucide-react'; +import { Bug, ChevronUp, ChevronDown, User, X, Sprout, Loader2 } from 'lucide-react'; import api from '../../lib/api'; interface DevUser { @@ -64,6 +64,29 @@ export function DevTools() { } }; + const [isReseeding, setIsReseeding] = useState(false); + const [reseedResult, setReseedResult] = useState(null); + + const handleReseedPlants = async () => { + if (!user) { + setError('Must be logged in as OWNER or ADMIN to reseed'); + return; + } + + setIsReseeding(true); + setError(null); + setReseedResult(null); + + try { + const response = await api.post('/admin/reseed-plants'); + setReseedResult(`✅ ${response.data.totalPlants} plants placed`); + } catch (err: any) { + setError(err.response?.data?.error || 'Reseed failed'); + } finally { + setIsReseeding(false); + } + }; + return (
{/* Toggle Button */} @@ -144,6 +167,28 @@ export function DevTools() {
+ {/* Demo Actions */} + {user && ['OWNER', 'ADMIN'].includes(user.role) && ( +
+

Demo Actions

+ + {reseedResult && ( +

{reseedResult}

+ )} +
+ )} + {/* Error */} {error && (