2026-03-21 21:48:56 -05:00
|
|
|
|
import { prisma } from '../lib/prisma';
|
|
|
|
|
|
import { AppError } from '../types/index';
|
|
|
|
|
|
import { MODULE_PORT_DEFAULTS, MODULE_U_DEFAULTS, type ModuleType, type PortType } from '../lib/constants';
|
|
|
|
|
|
|
|
|
|
|
|
const moduleInclude = {
|
|
|
|
|
|
ports: {
|
|
|
|
|
|
orderBy: { portNumber: 'asc' as const },
|
|
|
|
|
|
include: {
|
|
|
|
|
|
vlans: { include: { vlan: true } },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** Check whether a U-range is occupied in a rack, optionally excluding one module (for moves). */
|
|
|
|
|
|
async function hasCollision(
|
|
|
|
|
|
rackId: string,
|
|
|
|
|
|
uPosition: number,
|
|
|
|
|
|
uSize: number,
|
|
|
|
|
|
excludeModuleId?: string
|
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
|
const modules = await prisma.module.findMany({
|
|
|
|
|
|
where: { rackId, ...(excludeModuleId ? { id: { not: excludeModuleId } } : {}) },
|
|
|
|
|
|
select: { uPosition: true, uSize: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const occupied = new Set<number>();
|
|
|
|
|
|
for (const m of modules) {
|
|
|
|
|
|
for (let u = m.uPosition; u < m.uPosition + m.uSize; u++) occupied.add(u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (let u = uPosition; u < uPosition + uSize; u++) {
|
|
|
|
|
|
if (occupied.has(u)) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function createModule(
|
|
|
|
|
|
rackId: string,
|
|
|
|
|
|
data: {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
type: ModuleType;
|
|
|
|
|
|
uPosition: number;
|
|
|
|
|
|
uSize?: number;
|
|
|
|
|
|
manufacturer?: string;
|
|
|
|
|
|
model?: string;
|
|
|
|
|
|
ipAddress?: string;
|
|
|
|
|
|
notes?: string;
|
|
|
|
|
|
portCount?: number;
|
|
|
|
|
|
portType?: PortType;
|
2026-03-22 15:16:54 -05:00
|
|
|
|
sfpCount?: number;
|
|
|
|
|
|
wanCount?: number;
|
2026-03-21 21:48:56 -05:00
|
|
|
|
}
|
|
|
|
|
|
) {
|
|
|
|
|
|
const rack = await prisma.rack.findUnique({ where: { id: rackId } });
|
|
|
|
|
|
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
|
|
|
|
|
|
|
|
|
|
|
|
const uSize = data.uSize ?? MODULE_U_DEFAULTS[data.type] ?? 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (data.uPosition < 1 || data.uPosition + uSize - 1 > rack.totalU) {
|
|
|
|
|
|
throw new AppError(
|
|
|
|
|
|
`Module does not fit within rack (U1–U${rack.totalU})`,
|
|
|
|
|
|
400,
|
|
|
|
|
|
'OUT_OF_BOUNDS'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (await hasCollision(rackId, data.uPosition, uSize)) {
|
|
|
|
|
|
throw new AppError('U-slot collision: another module occupies that space', 409, 'COLLISION');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 15:16:54 -05:00
|
|
|
|
const sfpCount = data.sfpCount ?? (data.type === 'AGGREGATE_SWITCH' ? data.portCount ?? MODULE_PORT_DEFAULTS[data.type] : 0);
|
|
|
|
|
|
const wanCount = data.wanCount ?? 0;
|
|
|
|
|
|
// If aggregate switch is chosen, it usually uses its portCount as SFP ports, but it can be overridden.
|
|
|
|
|
|
// Standard ethernet port count is either the provided portCount or the default, adjusted if it's an aggregate switch (where default are SFP)
|
|
|
|
|
|
const ethernetCount = data.type === 'AGGREGATE_SWITCH'
|
|
|
|
|
|
? (data.portCount ? data.portCount : 0) // if user manually set portCount for Aggr, we treat it as ethernet (unlikely but possible)
|
|
|
|
|
|
: (data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0);
|
|
|
|
|
|
|
|
|
|
|
|
const portsToCreate = [];
|
|
|
|
|
|
let currentNum = 1;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. WAN/Uplink ports (often on the left or special)
|
|
|
|
|
|
for (let i = 0; i < wanCount; i++) {
|
|
|
|
|
|
portsToCreate.push({ portNumber: currentNum++, portType: 'WAN' as PortType });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Standard Ethernet ports
|
|
|
|
|
|
for (let i = 0; i < ethernetCount; i++) {
|
|
|
|
|
|
if (data.type === 'AGGREGATE_SWITCH' && !data.portCount) break; // skip if it's aggr and we handle them as SFPs below
|
|
|
|
|
|
portsToCreate.push({ portNumber: currentNum++, portType: 'ETHERNET' as PortType });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. SFP ports
|
|
|
|
|
|
for (let i = 0; i < sfpCount; i++) {
|
|
|
|
|
|
portsToCreate.push({ portNumber: currentNum++, portType: 'SFP' as PortType });
|
|
|
|
|
|
}
|
2026-03-21 21:48:56 -05:00
|
|
|
|
|
|
|
|
|
|
return prisma.module.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
rackId,
|
|
|
|
|
|
name: data.name,
|
|
|
|
|
|
type: data.type,
|
|
|
|
|
|
uPosition: data.uPosition,
|
|
|
|
|
|
uSize,
|
|
|
|
|
|
manufacturer: data.manufacturer,
|
|
|
|
|
|
model: data.model,
|
|
|
|
|
|
ipAddress: data.ipAddress,
|
|
|
|
|
|
notes: data.notes,
|
|
|
|
|
|
ports: {
|
2026-03-22 15:16:54 -05:00
|
|
|
|
create: portsToCreate,
|
2026-03-21 21:48:56 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
include: moduleInclude,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function updateModule(
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
data: Partial<{
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
uPosition: number;
|
|
|
|
|
|
uSize: number;
|
|
|
|
|
|
manufacturer: string;
|
|
|
|
|
|
model: string;
|
|
|
|
|
|
ipAddress: string;
|
|
|
|
|
|
notes: string;
|
|
|
|
|
|
}>
|
|
|
|
|
|
) {
|
|
|
|
|
|
const existing = await prisma.module.findUnique({ where: { id } });
|
|
|
|
|
|
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
|
|
|
|
|
|
|
|
|
|
|
|
const newPosition = data.uPosition ?? existing.uPosition;
|
|
|
|
|
|
const newSize = data.uSize ?? existing.uSize;
|
|
|
|
|
|
|
|
|
|
|
|
if (data.uPosition !== undefined || data.uSize !== undefined) {
|
|
|
|
|
|
const rack = await prisma.rack.findUnique({ where: { id: existing.rackId } });
|
|
|
|
|
|
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
|
|
|
|
|
|
|
|
|
|
|
|
if (newPosition < 1 || newPosition + newSize - 1 > rack.totalU) {
|
|
|
|
|
|
throw new AppError(
|
|
|
|
|
|
`Module does not fit within rack (U1–U${rack.totalU})`,
|
|
|
|
|
|
400,
|
|
|
|
|
|
'OUT_OF_BOUNDS'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (await hasCollision(existing.rackId, newPosition, newSize, id)) {
|
|
|
|
|
|
throw new AppError('U-slot collision', 409, 'COLLISION');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return prisma.module.update({ where: { id }, data, include: moduleInclude });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteModule(id: string) {
|
|
|
|
|
|
const existing = await prisma.module.findUnique({ where: { id } });
|
|
|
|
|
|
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
|
|
|
|
|
|
return prisma.module.delete({ where: { id } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 22:05:42 -05:00
|
|
|
|
/**
|
|
|
|
|
|
* Move a module to a new rack and/or U-position.
|
|
|
|
|
|
* Ports and VLAN assignments move with the module (they're linked by moduleId).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function moveModule(
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
targetRackId: string,
|
|
|
|
|
|
targetUPosition: number
|
|
|
|
|
|
) {
|
|
|
|
|
|
const existing = await prisma.module.findUnique({ where: { id } });
|
|
|
|
|
|
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
|
|
|
|
|
|
|
|
|
|
|
|
const targetRack = await prisma.rack.findUnique({ where: { id: targetRackId } });
|
|
|
|
|
|
if (!targetRack) throw new AppError('Target rack not found', 404, 'NOT_FOUND');
|
|
|
|
|
|
|
|
|
|
|
|
if (targetUPosition < 1 || targetUPosition + existing.uSize - 1 > targetRack.totalU) {
|
|
|
|
|
|
throw new AppError(
|
|
|
|
|
|
`Module does not fit within target rack (U1–U${targetRack.totalU})`,
|
|
|
|
|
|
400,
|
|
|
|
|
|
'OUT_OF_BOUNDS'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Collision check in target rack, excluding self (handles same-rack moves)
|
|
|
|
|
|
const excludeInTarget = targetRackId === existing.rackId ? id : undefined;
|
|
|
|
|
|
if (await hasCollision(targetRackId, targetUPosition, existing.uSize, excludeInTarget)) {
|
|
|
|
|
|
throw new AppError('U-slot collision in target rack', 409, 'COLLISION');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return prisma.module.update({
|
|
|
|
|
|
where: { id },
|
|
|
|
|
|
data: { rackId: targetRackId, uPosition: targetUPosition },
|
|
|
|
|
|
include: moduleInclude,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 21:48:56 -05:00
|
|
|
|
export async function getModulePorts(id: string) {
|
|
|
|
|
|
const existing = await prisma.module.findUnique({ where: { id } });
|
|
|
|
|
|
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
|
|
|
|
|
|
|
|
|
|
|
|
return prisma.port.findMany({
|
|
|
|
|
|
where: { moduleId: id },
|
|
|
|
|
|
orderBy: { portNumber: 'asc' },
|
|
|
|
|
|
include: { vlans: { include: { vlan: true } } },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|