ca-grow-ops-manager/backend/src/routes/layout.routes.ts
fullsizemalt ed36645cc8
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
feat(api): Update section creation routes to support Tiers
2025-12-11 13:23:32 -08:00

763 lines
29 KiB
TypeScript

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' });
}
}
});
}