feat: add Reseed Demo Plants button to DevTools (realistic plant layout)
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-18 12:00:22 -08:00
parent 916aedb278
commit f91fbc2237
3 changed files with 215 additions and 1 deletions

View file

@ -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<string, string[]> = {
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'
});
}
});
}

View file

@ -75,6 +75,10 @@ server.register(financialRoutes, { prefix: '/api/financial' });
server.register(insightsRoutes, { prefix: '/api/insights' }); server.register(insightsRoutes, { prefix: '/api/insights' });
server.register(uploadRoutes, { prefix: '/api/upload' }); 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) => { server.get('/api/healthz', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() }; return { status: 'ok', timestamp: new Date().toISOString() };
}); });

View file

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; 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'; import api from '../../lib/api';
interface DevUser { interface DevUser {
@ -64,6 +64,29 @@ export function DevTools() {
} }
}; };
const [isReseeding, setIsReseeding] = useState(false);
const [reseedResult, setReseedResult] = useState<string | null>(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 ( return (
<div className="fixed bottom-20 md:bottom-4 right-4 z-50"> <div className="fixed bottom-20 md:bottom-4 right-4 z-50">
{/* Toggle Button */} {/* Toggle Button */}
@ -144,6 +167,28 @@ export function DevTools() {
</div> </div>
</div> </div>
{/* Demo Actions */}
{user && ['OWNER', 'ADMIN'].includes(user.role) && (
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700">
<p className="text-[10px] uppercase text-slate-400 font-semibold mb-2">Demo Actions</p>
<button
onClick={handleReseedPlants}
disabled={isReseeding}
className="w-full flex items-center justify-center gap-2 p-2 rounded-lg text-sm bg-green-600 hover:bg-green-700 text-white transition-all disabled:opacity-50"
>
{isReseeding ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Sprout size={16} />
)}
Reseed Demo Plants
</button>
{reseedResult && (
<p className="text-xs text-green-600 mt-2 text-center">{reseedResult}</p>
)}
</div>
)}
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="px-4 pb-3"> <div className="px-4 pb-3">