import { FastifyInstance, FastifyPluginOptions } from 'fastify'; import { PrismaClient, RoomType, SectionType } from '@prisma/client'; const prisma = new PrismaClient(); export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPluginOptions) { // Authenticate all routes fastify.addHook('onRequest', async (request) => { try { await request.jwtVerify(); } catch (err) { throw err; } }); // ======================================== // PROPERTY ROUTES // ======================================== // Get all properties (with full hierarchy) fastify.get('/properties', { handler: async (request, reply) => { try { const properties = await prisma.facilityProperty.findMany({ include: { buildings: { include: { floors: { include: { rooms: true }, orderBy: { number: 'asc' } } } } } }); return properties; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch properties' }); } } }); // Create property with default building and floor fastify.post('/properties', { handler: async (request, reply) => { try { const { name, address, licenseNum } = request.body as any; const property = await prisma.facilityProperty.create({ data: { name, address, licenseNum, buildings: { create: { name: 'Main Building', code: 'MAIN', type: 'CULTIVATION', floors: { create: { name: 'Floor 1', number: 1, width: 200, height: 150 } } } } }, include: { buildings: { include: { floors: true } } } }); return reply.status(201).send(property); } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create property' }); } } }); // ======================================== // BUILDING ROUTES // ======================================== // Create building fastify.post('/buildings', { handler: async (request, reply) => { try { const { propertyId, name, code, type } = request.body as any; const building = await prisma.facilityBuilding.create({ data: { propertyId, name, code, type, floors: { create: { name: 'Floor 1', number: 1, width: 200, height: 150 } } }, include: { floors: true } }); return reply.status(201).send(building); } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create building' }); } } }); // ======================================== // FLOOR ROUTES // ======================================== // Get floor with rooms fastify.get('/floors/:id', { handler: async (request, reply) => { try { const { id } = request.params as any; const floor = await prisma.facilityFloor.findUnique({ where: { id }, include: { rooms: { include: { sections: true } }, building: { include: { property: true } } } }); if (!floor) { return reply.status(404).send({ error: 'Floor not found' }); } return floor; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch floor' }); } } }); // Create floor fastify.post('/floors', { handler: async (request, reply) => { try { const { buildingId, name, number, width, height, ceilingHeight, defaultTiers } = request.body as any; const floor = await prisma.facilityFloor.create({ data: { buildingId, name, number, width: width || 200, height: height || 150, ceilingHeight: ceilingHeight || null, defaultTiers: defaultTiers || 1 }, include: { rooms: true } }); return reply.status(201).send(floor); } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create floor' }); } } }); // ======================================== // ROOM ROUTES // ======================================== // Create room fastify.post('/rooms', { handler: async (request, reply) => { try { const { floorId, name, code, type, posX, posY, width, height, color } = request.body as any; const room = await prisma.facilityRoom.create({ data: { floorId, name, code, type: type as RoomType, posX, posY, width, height, color } }); return reply.status(201).send(room); } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create room' }); } } }); // Update room fastify.put('/rooms/:id', { handler: async (request, reply) => { try { const { id } = request.params as any; const { name, code, type, posX, posY, width, height, color, rotation } = request.body as any; const room = await prisma.facilityRoom.update({ where: { id }, data: { name, code, type: type as RoomType, posX, posY, width, height, color, rotation } }); return room; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to update room' }); } } }); // Delete room fastify.delete('/rooms/:id', { handler: async (request, reply) => { try { const { id } = request.params as any; await prisma.facilityRoom.delete({ where: { id } }); return reply.status(204).send(); } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to delete room' }); } } }); // Get all sections for a room (with positions) fastify.get('/rooms/:id/sections', { handler: async (request, reply) => { try { const { id } = request.params as any; const sections = await prisma.facilitySection.findMany({ where: { roomId: id }, include: { positions: { include: { plant: true }, orderBy: [{ row: 'asc' }, { column: 'asc' }] } } }); return sections; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch room sections' }); } } }); // Occupy Position (Place Plant) fastify.post('/positions/:id/occupy', { handler: async (request, reply) => { try { const { id } = request.params as any; const { batchId } = request.body as any; const position = await prisma.facilityPosition.findUnique({ where: { id }, include: { section: { include: { room: true } } } }); if (!position) return reply.status(404).send({ error: 'Position not found' }); // Generate Address String (ROOM-SEC-R-C) const sec = position.section; const room = sec.room; const address = `${room.code}-${sec.code}-${position.row}-${position.column}`; await prisma.$transaction(async (tx) => { // Check if already occupied const existing = await tx.facilityPlant.findUnique({ where: { positionId: id } }); if (existing) throw new Error('Position already occupied'); await tx.facilityPlant.create({ data: { tagNumber: `P-${Date.now()}-${Math.floor(Math.random() * 1000)}`, batchId, positionId: id, address, status: 'ACTIVE' } }); await tx.facilityPosition.update({ where: { id }, data: { status: 'PLANTED' } }); }); return { success: true }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to place plant' }); } } }); // Move Plant to a new position fastify.post('/plants/:id/move', { handler: async (request, reply) => { try { const { id } = request.params as any; const { targetPositionId, reason } = request.body as any; const userId = (request.user as any)?.id || 'system'; // Get plant with current position const plant = await prisma.facilityPlant.findUnique({ where: { id }, include: { position: { include: { section: { include: { room: true } } } } } }); if (!plant) return reply.status(404).send({ error: 'Plant not found' }); // Get target position const targetPosition = await prisma.facilityPosition.findUnique({ where: { id: targetPositionId }, include: { section: { include: { room: true } }, plant: true } }); if (!targetPosition) return reply.status(404).send({ error: 'Target position not found' }); if (targetPosition.plant) return reply.status(400).send({ error: 'Target position is occupied' }); const oldAddress = plant.address; const newRoom = targetPosition.section.room; const newSection = targetPosition.section; const newAddress = `${newRoom.code}-${newSection.code}-${targetPosition.row}-${targetPosition.column}`; await prisma.$transaction(async (tx) => { // Clear old position await tx.facilityPosition.update({ where: { id: plant.positionId }, data: { status: 'EMPTY' } }); // Update plant await tx.facilityPlant.update({ where: { id }, data: { positionId: targetPositionId, address: newAddress } }); // Update new position await tx.facilityPosition.update({ where: { id: targetPositionId }, data: { status: 'PLANTED' } }); // Record history await tx.plantLocationHistory.create({ data: { plantId: id, fromAddress: oldAddress, toAddress: newAddress, movedById: userId, reason: reason || 'REORGANIZE' } }); }); return { success: true, newAddress, message: `Moved plant from ${oldAddress} to ${newAddress}` }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to move plant' }); } } }); // Bulk Fill Section (Place plants in all empty positions) fastify.post('/sections/:id/fill', { handler: async (request, reply) => { try { const { id } = request.params as any; const { batchId, maxCount } = request.body as any; // Get section with room info for address generation const section = await prisma.facilitySection.findUnique({ where: { id }, include: { room: true, positions: { where: { status: 'EMPTY' }, orderBy: [{ row: 'asc' }, { column: 'asc' }] } } }); if (!section) return reply.status(404).send({ error: 'Section not found' }); const emptyPositions = section.positions; const positionsToFill = maxCount ? emptyPositions.slice(0, maxCount) : emptyPositions; if (positionsToFill.length === 0) { return reply.status(400).send({ error: 'No empty positions available' }); } const room = section.room; let plantsCreated = 0; await prisma.$transaction(async (tx) => { for (const pos of positionsToFill) { const address = `${room.code}-${section.code}-${pos.row}-${pos.column}`; await tx.facilityPlant.create({ data: { tagNumber: `P-${Date.now()}-${pos.row}${pos.column}-${Math.floor(Math.random() * 100)}`, batchId, positionId: pos.id, address, status: 'ACTIVE' } }); await tx.facilityPosition.update({ where: { id: pos.id }, data: { status: 'PLANTED' } }); plantsCreated++; } }); return { success: true, plantsCreated, message: `Placed ${plantsCreated} plants in ${section.name}` }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fill section' }); } } }); // ======================================== // SECTION ROUTES // ======================================== // Create section with positions fastify.post('/sections', { handler: async (request, reply) => { try { const { roomId, name, code, type, posX, posY, width, height, rows, columns, spacing, tiers } = request.body as any; const numTiers = tiers || 1; // Generate positions for all rows, columns, and tiers const positionsToCreate = []; for (let r = 1; r <= rows; r++) { for (let c = 1; c <= columns; c++) { for (let t = 1; t <= numTiers; t++) { positionsToCreate.push({ row: r, column: c, tier: t, slot: 1, status: 'EMPTY' }); } } } const section = await prisma.facilitySection.create({ data: { roomId, name, code, type: type as SectionType, posX, posY, width, height, rows, columns, tiers: numTiers, spacing: spacing || 12, positions: { create: positionsToCreate } }, include: { positions: true } }); return reply.status(201).send(section); } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create section' }); } } }); // Get section with positions fastify.get('/sections/:id', { handler: async (request, reply) => { try { const { id } = request.params as any; const section = await prisma.facilitySection.findUnique({ where: { id }, include: { positions: { include: { plant: true }, orderBy: [{ row: 'asc' }, { column: 'asc' }] }, room: true } }); if (!section) { return reply.status(404).send({ error: 'Section not found' }); } return section; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch section' }); } } }); // ======================================== // BULK SAVE LAYOUT // ======================================== // Save entire floor layout fastify.post('/floors/:id/layout', { handler: async (request, reply) => { try { const { id: floorId } = request.params as any; const { rooms } = request.body as any; await prisma.$transaction(async (tx) => { // Get existing room IDs const existingRooms = await tx.facilityRoom.findMany({ where: { floorId }, select: { id: true } }); const existingIds = new Set(existingRooms.map(r => r.id)); const incomingIds = new Set((rooms as any[]).map(r => r.id)); // Process each room for (const room of rooms) { const roomData = { name: room.name, code: room.code, type: room.type, posX: room.posX, posY: room.posY, width: room.width, height: room.height, color: room.color, rotation: room.rotation || 0 }; if (existingIds.has(room.id)) { // Update existing room await tx.facilityRoom.update({ where: { id: room.id }, data: roomData }); } else { // Create new room await tx.facilityRoom.create({ data: { id: room.id, floorId, ...roomData } }); } // Handle Sections const sections = room.sections || []; const existingSections = await tx.facilitySection.findMany({ where: { roomId: room.id }, select: { id: true } }); const existingSecIds = new Set(existingSections.map(s => s.id)); const incomingSecIds = new Set(sections.map((s: any) => s.id)); for (const sec of sections) { const secData = { roomId: room.id, name: sec.name, code: sec.code, type: sec.type, posX: sec.posX, posY: sec.posY, width: sec.width, height: sec.height, rows: sec.rows, columns: sec.columns, tiers: sec.tiers || 1, spacing: sec.spacing || 12 }; if (existingSecIds.has(sec.id)) { // Update existing section await tx.facilitySection.update({ where: { id: sec.id }, data: secData }); } else { // Create new section with positions const numTiers = sec.tiers || 1; const positionsToCreate = []; for (let r = 1; r <= sec.rows; r++) { for (let c = 1; c <= sec.columns; c++) { for (let t = 1; t <= numTiers; t++) { positionsToCreate.push({ row: r, column: c, tier: t, slot: 1, status: 'EMPTY' }); } } } await tx.facilitySection.create({ data: { id: sec.id, ...secData, positions: { create: positionsToCreate } } }); } } // Delete removed sections const secToDelete = [...existingSecIds].filter(id => !incomingSecIds.has(id)); if (secToDelete.length > 0) { await tx.facilitySection.deleteMany({ where: { id: { in: secToDelete } } }); } } // Delete rooms that were removed const toDelete = [...existingIds].filter(id => !incomingIds.has(id)); if (toDelete.length > 0) { await tx.facilityRoom.deleteMany({ where: { id: { in: toDelete } } }); } }); // Return updated floor const floor = await prisma.facilityFloor.findUnique({ where: { id: floorId }, include: { rooms: { include: { sections: true } } } }); return floor; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to save layout' }); } } }); // ======================================== // POSITION ROUTES // ======================================== // Get hierarchical address for a position fastify.get('/positions/:id/address', { handler: async (request, reply) => { try { const { id } = request.params as any; const position = await prisma.facilityPosition.findUnique({ where: { id }, include: { section: { include: { room: { include: { floor: { include: { building: { include: { property: true } } } } } } } } } }); if (!position) { return reply.status(404).send({ error: 'Position not found' }); } // Construct hierarchical address // Format: PROP.BLDG.FLR.RM.SEC.R{Row}.C{Col}.S{Slot} const p = position.section.room.floor.building.property; const b = position.section.room.floor.building; const f = position.section.room.floor; const r = position.section.room; const s = position.section; // Helper to sanitize codes (remove spaces, uppercase) const code = (str: string) => str.toUpperCase().replace(/[^A-Z0-9]/g, ''); const address = [ code(p.name).substring(0, 4), code(b.code), `F${f.number}`, code(r.code), code(s.code), `R${position.row}`, `C${position.column}` ].join('.'); return { address, details: position }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to generate address' }); } } }); }