Initial scaffold: full-stack RackMapper application

Complete project scaffold with working auth, REST API, Prisma/SQLite
schema, Docker config, and React frontend for both Rack Planner and
Service Mapper modules. Both server and client pass TypeScript strict
mode with zero errors. Initial migration applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 21:48:56 -05:00
parent 61a4d37d94
commit 231de3d005
79 changed files with 12983 additions and 0 deletions

63
server/index.ts Normal file
View File

@@ -0,0 +1,63 @@
import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import path from 'path';
import { authRouter } from './routes/auth';
import { racksRouter } from './routes/racks';
import { modulesRouter } from './routes/modules';
import { portsRouter } from './routes/ports';
import { vlansRouter } from './routes/vlans';
import { serviceMapRouter } from './routes/serviceMap';
import { nodesRouter } from './routes/nodes';
import { edgesRouter } from './routes/edges';
import { authMiddleware } from './middleware/authMiddleware';
import { errorHandler } from './middleware/errorHandler';
const app = express();
const PORT = process.env.PORT ?? 3001;
// ---- Core middleware ----
app.use(express.json());
app.use(cookieParser());
// CORS only needed in local dev (Vite :5173 → Node :3001)
if (process.env.NODE_ENV !== 'production') {
app.use(
cors({
origin: 'http://localhost:5173',
credentials: true,
})
);
}
// ---- Auth routes (no JWT required) ----
app.use('/api/auth', authRouter);
// ---- Protected API routes ----
app.use('/api', authMiddleware);
app.use('/api/racks', racksRouter);
app.use('/api/modules', modulesRouter);
app.use('/api/ports', portsRouter);
app.use('/api/vlans', vlansRouter);
app.use('/api/maps', serviceMapRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/edges', edgesRouter);
// ---- Serve Vite build in production ----
if (process.env.NODE_ENV === 'production') {
const clientDist = path.join(process.cwd(), 'client', 'dist');
app.use(express.static(clientDist));
// SPA fallback — always serve index.html for non-API routes
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
}
// ---- Error handler (must be last) ----
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`RackMapper running on port ${PORT} [${process.env.NODE_ENV ?? 'development'}]`);
});

44
server/lib/constants.ts Normal file
View File

@@ -0,0 +1,44 @@
// SQLite doesn't support Prisma enums — use string literals throughout the server.
// These types mirror client/src/types/index.ts
export type ModuleType =
| 'SWITCH' | 'AGGREGATE_SWITCH' | 'MODEM' | 'ROUTER' | 'NAS'
| 'PDU' | 'PATCH_PANEL' | 'SERVER' | 'FIREWALL' | 'AP' | 'BLANK' | 'OTHER';
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK';
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
export type NodeType =
| 'SERVICE' | 'DATABASE' | 'API' | 'DEVICE' | 'EXTERNAL'
| 'USER' | 'VLAN' | 'FIREWALL' | 'LOAD_BALANCER' | 'NOTE';
export const MODULE_PORT_DEFAULTS: Record<ModuleType, number> = {
SWITCH: 24,
AGGREGATE_SWITCH: 8,
ROUTER: 4,
FIREWALL: 8,
PATCH_PANEL: 24,
AP: 1,
MODEM: 2,
SERVER: 2,
NAS: 1,
PDU: 12,
BLANK: 0,
OTHER: 0,
};
export const MODULE_U_DEFAULTS: Record<ModuleType, number> = {
SWITCH: 1,
AGGREGATE_SWITCH: 2,
ROUTER: 1,
FIREWALL: 1,
PATCH_PANEL: 1,
AP: 1,
MODEM: 1,
SERVER: 2,
NAS: 4,
PDU: 1,
BLANK: 1,
OTHER: 1,
};

14
server/lib/prisma.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
// Singleton pattern prevents multiple PrismaClient instances in dev (hot reload)
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

View File

@@ -0,0 +1,26 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError, AuthenticatedRequest } from '../types/index';
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const token = (req.cookies as Record<string, string | undefined>)?.token;
if (!token) {
next(new AppError('Unauthorized', 401, 'NO_TOKEN'));
return;
}
const secret = process.env.JWT_SECRET;
if (!secret) {
next(new AppError('Server misconfiguration: JWT_SECRET not set', 500, 'CONFIG_ERROR'));
return;
}
try {
const payload = jwt.verify(token, secret) as { sub: string };
(req as AuthenticatedRequest).user = { sub: payload.sub };
next();
} catch {
next(new AppError('Invalid or expired session', 401, 'INVALID_TOKEN'));
}
}

View File

@@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from 'express';
import { AppError, err } from '../types/index';
export function errorHandler(
error: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
const statusCode = error instanceof AppError ? error.statusCode : 500;
const code = error instanceof AppError ? error.code : 'INTERNAL_ERROR';
const message =
process.env.NODE_ENV === 'production' && statusCode === 500
? 'Internal server error'
: error.message;
if (statusCode === 500 && process.env.NODE_ENV !== 'production') {
console.error('[ErrorHandler]', error);
}
res.status(statusCode).json(err(message, code ? { code } : undefined));
}

60
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,60 @@
import { Router, Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { AppError, ok } from '../types/index';
import { authMiddleware } from '../middleware/authMiddleware';
export const authRouter = Router();
const COOKIE_OPTS = {
httpOnly: true,
sameSite: 'strict' as const,
secure: process.env.NODE_ENV === 'production',
path: '/',
};
authRouter.post('/login', async (req: Request, res: Response, next: NextFunction) => {
try {
const { username, password } = req.body as { username?: string; password?: string };
if (!username || !password) {
throw new AppError('Username and password are required', 400, 'MISSING_FIELDS');
}
const adminUsername = process.env.ADMIN_USERNAME;
const adminHash = process.env.ADMIN_PASSWORD_HASH;
if (!adminUsername || !adminHash) {
throw new AppError('Server not configured: admin credentials missing', 500, 'CONFIG_ERROR');
}
const usernameMatch = username === adminUsername;
// Always run bcrypt to prevent timing attacks even if username is wrong
const passwordMatch = await bcrypt.compare(password, adminHash);
if (!usernameMatch || !passwordMatch) {
throw new AppError('Invalid username or password', 401, 'INVALID_CREDENTIALS');
}
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError('Server not configured: JWT_SECRET missing', 500, 'CONFIG_ERROR');
const token = jwt.sign({ sub: 'admin' }, secret, {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
});
res.cookie('token', token, COOKIE_OPTS);
res.json(ok({ success: true }));
} catch (e) {
next(e);
}
});
authRouter.post('/logout', (_req: Request, res: Response) => {
res.clearCookie('token', COOKIE_OPTS);
res.json(ok({ success: true }));
});
authRouter.get('/me', authMiddleware, (_req: Request, res: Response) => {
res.json(ok({ authenticated: true }));
});

28
server/routes/edges.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
export const edgesRouter = Router();
edgesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, edgeType, animated, metadata } = req.body as {
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
};
res.json(ok(await mapService.updateEdge(req.params.id, { label, edgeType, animated, metadata })));
} catch (e) {
next(e);
}
});
edgesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteEdge(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});

51
server/routes/modules.ts Normal file
View File

@@ -0,0 +1,51 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as moduleService from '../services/moduleService';
import { ok } from '../types/index';
export const modulesRouter = Router();
modulesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, uPosition, uSize, manufacturer, model, ipAddress, notes } = req.body as {
name?: string;
uPosition?: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
};
res.json(
ok(
await moduleService.updateModule(req.params.id, {
name,
uPosition,
uSize,
manufacturer,
model,
ipAddress,
notes,
})
)
);
} catch (e) {
next(e);
}
});
modulesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await moduleService.deleteModule(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
modulesRouter.get('/:id/ports', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await moduleService.getModulePorts(req.params.id)));
} catch (e) {
next(e);
}
});

33
server/routes/nodes.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
export const nodesRouter = Router();
nodesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
label?: string;
positionX?: number;
positionY?: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string | null;
};
res.json(
ok(await mapService.updateNode(req.params.id, { label, positionX, positionY, metadata, color, icon, moduleId }))
);
} catch (e) {
next(e);
}
});
nodesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteNode(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});

21
server/routes/ports.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as portService from '../services/portService';
import { ok } from '../types/index';
import type { VlanMode } from '../lib/constants';
export const portsRouter = Router();
portsRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, mode, nativeVlan, notes, vlans } = req.body as {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
};
res.json(ok(await portService.updatePort(req.params.id, { label, mode, nativeVlan, notes, vlans })));
} catch (e) {
next(e);
}
});

83
server/routes/racks.ts Normal file
View File

@@ -0,0 +1,83 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as rackService from '../services/rackService';
import * as moduleService from '../services/moduleService';
import { ok } from '../types/index';
import type { ModuleType, PortType } from '../lib/constants';
export const racksRouter = Router();
racksRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await rackService.listRacks()));
} catch (e) {
next(e);
}
});
racksRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, totalU, location, displayOrder } = req.body as {
name: string;
totalU?: number;
location?: string;
displayOrder?: number;
};
res.status(201).json(ok(await rackService.createRack({ name, totalU, location, displayOrder })));
} catch (e) {
next(e);
}
});
racksRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await rackService.getRack(req.params.id)));
} catch (e) {
next(e);
}
});
racksRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, totalU, location, displayOrder } = req.body as {
name?: string;
totalU?: number;
location?: string;
displayOrder?: number;
};
res.json(ok(await rackService.updateRack(req.params.id, { name, totalU, location, displayOrder })));
} catch (e) {
next(e);
}
});
racksRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await rackService.deleteRack(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType } =
req.body as {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
};
res.status(201).json(
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType }))
);
} catch (e) {
next(e);
}
});

View File

@@ -0,0 +1,97 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
import type { NodeType } from '../lib/constants';
export const serviceMapRouter = Router();
serviceMapRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.listMaps()));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description } = req.body as { name: string; description?: string };
res.status(201).json(ok(await mapService.createMap({ name, description })));
} catch (e) {
next(e);
}
});
serviceMapRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.getMap(req.params.id)));
} catch (e) {
next(e);
}
});
serviceMapRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description } = req.body as { name?: string; description?: string };
res.json(ok(await mapService.updateMap(req.params.id, { name, description })));
} catch (e) {
next(e);
}
});
serviceMapRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteMap(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/nodes', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, nodeType, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
};
res.status(201).json(
ok(await mapService.addNode(req.params.id, { label, nodeType, positionX, positionY, metadata, color, icon, moduleId }))
);
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/populate', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.populateFromRack(req.params.id)));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/edges', async (req: Request, res: Response, next: NextFunction) => {
try {
const { sourceId, targetId, label, edgeType, animated, metadata } = req.body as {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
};
res.status(201).json(
ok(await mapService.addEdge(req.params.id, { sourceId, targetId, label, edgeType, animated, metadata }))
);
} catch (e) {
next(e);
}
});
export { mapService };

49
server/routes/vlans.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as vlanService from '../services/vlanService';
import { ok } from '../types/index';
export const vlansRouter = Router();
vlansRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await vlanService.listVlans()));
} catch (e) {
next(e);
}
});
vlansRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { vlanId, name, description, color } = req.body as {
vlanId: number;
name: string;
description?: string;
color?: string;
};
res.status(201).json(ok(await vlanService.createVlan({ vlanId, name, description, color })));
} catch (e) {
next(e);
}
});
vlansRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description, color } = req.body as {
name?: string;
description?: string;
color?: string;
};
res.json(ok(await vlanService.updateVlan(req.params.id, { name, description, color })));
} catch (e) {
next(e);
}
});
vlansRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await vlanService.deleteVlan(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});

View File

@@ -0,0 +1,170 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import type { NodeType } from '../lib/constants';
const mapInclude = {
nodes: {
include: { module: true },
},
edges: true,
};
export async function listMaps() {
return prisma.serviceMap.findMany({
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, description: true, createdAt: true, updatedAt: true },
});
}
export async function getMap(id: string) {
const map = await prisma.serviceMap.findUnique({ where: { id }, include: mapInclude });
if (!map) throw new AppError('Map not found', 404, 'NOT_FOUND');
return map;
}
export async function createMap(data: { name: string; description?: string }) {
return prisma.serviceMap.create({ data, include: mapInclude });
}
export async function updateMap(id: string, data: Partial<{ name: string; description: string }>) {
await getMap(id);
return prisma.serviceMap.update({ where: { id }, data, include: mapInclude });
}
export async function deleteMap(id: string) {
await getMap(id);
return prisma.serviceMap.delete({ where: { id } });
}
// ---- Nodes ----
export async function addNode(
mapId: string,
data: {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
}
) {
await getMap(mapId);
return prisma.serviceNode.create({
data: { mapId, ...data },
include: { module: true },
});
}
export async function populateFromRack(mapId: string) {
await getMap(mapId);
const modules = await prisma.module.findMany({
orderBy: [{ rack: { displayOrder: 'asc' } }, { uPosition: 'asc' }],
include: { rack: true },
});
const existing = await prisma.serviceNode.findMany({
where: { mapId, moduleId: { not: null } },
select: { moduleId: true },
});
const existingModuleIds = new Set(existing.map((n) => n.moduleId as string));
const newModules = modules.filter((m) => !existingModuleIds.has(m.id));
if (newModules.length === 0) return getMap(mapId);
const byRack = new Map<string, typeof modules>();
for (const mod of newModules) {
if (!byRack.has(mod.rackId)) byRack.set(mod.rackId, []);
byRack.get(mod.rackId)!.push(mod);
}
const NODE_W = 200;
const NODE_H = 80;
const COL_GAP = 260;
const ROW_GAP = 110;
const nodesToCreate: Array<{
mapId: string;
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
moduleId: string;
}> = [];
let colIdx = 0;
for (const rackModules of byRack.values()) {
rackModules.forEach((mod, rowIdx) => {
nodesToCreate.push({
mapId,
label: mod.name,
nodeType: 'DEVICE' as NodeType,
positionX: colIdx * (NODE_W + COL_GAP),
positionY: rowIdx * (NODE_H + ROW_GAP),
moduleId: mod.id,
});
});
colIdx++;
}
await prisma.serviceNode.createMany({ data: nodesToCreate });
return getMap(mapId);
}
export async function updateNode(
id: string,
data: Partial<{
label: string;
positionX: number;
positionY: number;
metadata: string;
color: string;
icon: string;
moduleId: string | null;
}>
) {
const existing = await prisma.serviceNode.findUnique({ where: { id } });
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
return prisma.serviceNode.update({ where: { id }, data, include: { module: true } });
}
export async function deleteNode(id: string) {
const existing = await prisma.serviceNode.findUnique({ where: { id } });
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
return prisma.serviceNode.delete({ where: { id } });
}
// ---- Edges ----
export async function addEdge(
mapId: string,
data: {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
}
) {
await getMap(mapId);
return prisma.serviceEdge.create({ data: { mapId, ...data } });
}
export async function updateEdge(
id: string,
data: Partial<{ label: string; edgeType: string; animated: boolean; metadata: string }>
) {
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
return prisma.serviceEdge.update({ where: { id }, data });
}
export async function deleteEdge(id: string) {
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
return prisma.serviceEdge.delete({ where: { id } });
}

View File

@@ -0,0 +1,147 @@
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;
}
) {
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 (U1U${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');
}
const portCount = data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0;
const portType: PortType = data.portType ?? 'ETHERNET';
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: {
create: Array.from({ length: portCount }, (_, i) => ({
portNumber: i + 1,
portType,
})),
},
},
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 (U1U${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 } });
}
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 } } },
});
}

View File

@@ -0,0 +1,47 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import type { VlanMode } from '../lib/constants';
const portInclude = {
vlans: { include: { vlan: true } },
};
export async function updatePort(
id: string,
data: {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
}
) {
const existing = await prisma.port.findUnique({ where: { id } });
if (!existing) throw new AppError('Port not found', 404, 'NOT_FOUND');
const { vlans: vlanAssignments, ...portData } = data;
return prisma.$transaction(async (tx) => {
await tx.port.update({ where: { id }, data: portData });
if (vlanAssignments !== undefined) {
if (vlanAssignments.length > 0) {
const vlanIds = vlanAssignments.map((v) => v.vlanId);
const found = await tx.vlan.findMany({ where: { id: { in: vlanIds } } });
if (found.length !== vlanIds.length) {
throw new AppError('One or more VLANs not found', 404, 'VLAN_NOT_FOUND');
}
}
await tx.portVlan.deleteMany({ where: { portId: id } });
if (vlanAssignments.length > 0) {
await tx.portVlan.createMany({
data: vlanAssignments.map(({ vlanId, tagged }) => ({ portId: id, vlanId, tagged })),
});
}
}
return tx.port.findUnique({ where: { id }, include: portInclude });
});
}

View File

@@ -0,0 +1,59 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
// Full include shape used across all rack queries
const rackInclude = {
modules: {
orderBy: { uPosition: 'asc' as const },
include: {
ports: {
orderBy: { portNumber: 'asc' as const },
include: {
vlans: {
include: { vlan: true },
},
},
},
},
},
};
export async function listRacks() {
return prisma.rack.findMany({
orderBy: { displayOrder: 'asc' },
include: rackInclude,
});
}
export async function getRack(id: string) {
const rack = await prisma.rack.findUnique({ where: { id }, include: rackInclude });
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
return rack;
}
export async function createRack(data: {
name: string;
totalU?: number;
location?: string;
displayOrder?: number;
}) {
// Auto-assign displayOrder to end of list if not provided
if (data.displayOrder === undefined) {
const last = await prisma.rack.findFirst({ orderBy: { displayOrder: 'desc' } });
data.displayOrder = last ? last.displayOrder + 1 : 0;
}
return prisma.rack.create({ data, include: rackInclude });
}
export async function updateRack(
id: string,
data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>
) {
await getRack(id); // throws 404 if missing
return prisma.rack.update({ where: { id }, data, include: rackInclude });
}
export async function deleteRack(id: string) {
await getRack(id);
return prisma.rack.delete({ where: { id } });
}

View File

@@ -0,0 +1,37 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
export async function listVlans() {
return prisma.vlan.findMany({ orderBy: { vlanId: 'asc' } });
}
export async function createVlan(data: {
vlanId: number;
name: string;
description?: string;
color?: string;
}) {
const existing = await prisma.vlan.findUnique({ where: { vlanId: data.vlanId } });
if (existing) throw new AppError(`VLAN ID ${data.vlanId} already exists`, 409, 'DUPLICATE');
if (data.vlanId < 1 || data.vlanId > 4094) {
throw new AppError('VLAN ID must be between 1 and 4094', 400, 'INVALID_VLAN_ID');
}
return prisma.vlan.create({ data });
}
export async function updateVlan(
id: string,
data: Partial<{ name: string; description: string; color: string }>
) {
const existing = await prisma.vlan.findUnique({ where: { id } });
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
return prisma.vlan.update({ where: { id }, data });
}
export async function deleteVlan(id: string) {
const existing = await prisma.vlan.findUnique({ where: { id } });
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
return prisma.vlan.delete({ where: { id } });
}

37
server/types/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Request } from 'express';
// ---- Error handling ----
export class AppError extends Error {
statusCode: number;
code?: string;
constructor(message: string, statusCode: number, code?: string) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
// ---- API response shape ----
export interface ApiResponse<T = unknown> {
data: T | null;
error: string | null;
meta?: Record<string, unknown>;
}
export function ok<T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> {
return { data, error: null, ...(meta ? { meta } : {}) };
}
export function err(message: string, meta?: Record<string, unknown>): ApiResponse<null> {
return { data: null, error: message, ...(meta ? { meta } : {}) };
}
// ---- Augmented request ----
export interface AuthenticatedRequest extends Request {
user: { sub: string };
}