feat: complete house plan maker application
Full-featured house/apartment floor plan editor with: - Turborepo monorepo (React/Vite client, Fastify/Prisma server, shared Zod schemas) - 2D room editor with walls, doors, windows, furniture, electrical elements - 3D room preview with Three.js (auto-hide nearest walls, bird's eye default) - Wall projection views with interactive drag (elevation, position) - Apartment floor plan view with room positioning - Copy/paste, alignment tools, measurement tool, annotations - Item-attached annotations with leader lines (visible on projections) - Door open direction (LEFT/RIGHT/INWARD/OUTWARD) with swing arc - Floor type textures (wood, tile, concrete, laminate, herringbone) - Wall color picker for 3D view - Furniture: bed, desk, wardrobe, sofa, table, chair, shelf, nightstand, dresser, bookcase, TV (with stand toggle), AC unit - Furniture elevation support (wall-mounted items) - Auto-save with dirty state tracking, batch save API - Rotation-aware collision detection (SAT/OBB) with 3D elevation check - Rotation-aware hit testing - i18n (English/Russian) with locale-aware number formatting - Dark mode with system preference detection - Undo/redo, keyboard shortcuts, scale bar - PDF/PNG/JSON export and JSON import - Focus trap modal, toast notifications, tooltips - Responsive layout with overlay palettes
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import Fastify from 'fastify';
|
||||
import type { HealthCheckResponse } from '@house-plan-maker/shared';
|
||||
import prismaPlugin from './plugins/prisma.js';
|
||||
import errorHandlerPlugin from './plugins/error-handler.js';
|
||||
import apartmentRoutes from './routes/apartments.js';
|
||||
import roomRoutes from './routes/rooms.js';
|
||||
import elementRoutes from './routes/elements.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3001', 10);
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
const server = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
// Plugins
|
||||
server.register(prismaPlugin);
|
||||
server.register(errorHandlerPlugin);
|
||||
|
||||
// Routes
|
||||
server.register(apartmentRoutes);
|
||||
server.register(roomRoutes);
|
||||
server.register(elementRoutes);
|
||||
|
||||
server.get('/api/health', async (_request, _reply): Promise<HealthCheckResponse> => {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await server.listen({ port: PORT, host: HOST });
|
||||
server.log.info(`Server listening on http://${HOST}:${PORT}`);
|
||||
} catch (err) {
|
||||
server.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
interface ErrorResponseBody {
|
||||
readonly error: string;
|
||||
readonly statusCode: number;
|
||||
}
|
||||
|
||||
const errorHandlerPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
fastify.setErrorHandler((error: unknown, _request, reply) => {
|
||||
const fastifyError = error as { statusCode?: number; message?: string };
|
||||
const statusCode = fastifyError.statusCode ?? 500;
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
const message = error.issues
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join('; ');
|
||||
|
||||
const body: ErrorResponseBody = {
|
||||
error: `Validation error: ${message}`,
|
||||
statusCode: 400,
|
||||
};
|
||||
|
||||
return reply.status(400).send(body);
|
||||
}
|
||||
|
||||
const body: ErrorResponseBody = {
|
||||
error: statusCode >= 500 ? 'Internal Server Error' : (fastifyError.message ?? 'Unknown error'),
|
||||
statusCode,
|
||||
};
|
||||
|
||||
if (statusCode >= 500) {
|
||||
fastify.log.error(error);
|
||||
}
|
||||
|
||||
return reply.status(statusCode).send(body);
|
||||
});
|
||||
|
||||
fastify.setNotFoundHandler((_request, reply) => {
|
||||
const body: ErrorResponseBody = {
|
||||
error: 'Not Found',
|
||||
statusCode: 404,
|
||||
};
|
||||
return reply.status(404).send(body);
|
||||
});
|
||||
};
|
||||
|
||||
export default fp(errorHandlerPlugin, { name: 'error-handler' });
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
|
||||
const prismaPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
await prisma.$connect();
|
||||
|
||||
fastify.decorate('prisma', prisma);
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
};
|
||||
|
||||
export default fp(prismaPlugin, { name: 'prisma' });
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import {
|
||||
createApartmentSchema,
|
||||
updateApartmentSchema,
|
||||
} from '@house-plan-maker/shared';
|
||||
import type {
|
||||
Apartment,
|
||||
ApartmentWithRooms,
|
||||
ApiResponse,
|
||||
ApiListResponse,
|
||||
} from '@house-plan-maker/shared';
|
||||
|
||||
const apartmentRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
const { prisma } = fastify;
|
||||
|
||||
// GET /api/apartments
|
||||
fastify.get('/api/apartments', async (): Promise<ApiListResponse<Apartment>> => {
|
||||
const apartments = await prisma.apartment.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: { _count: { select: { rooms: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
data: apartments.map((apt) => ({
|
||||
...toApartmentResponse(apt),
|
||||
roomCount: apt._count.rooms,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/apartments
|
||||
fastify.post('/api/apartments', async (request, reply): Promise<ApiResponse<Apartment>> => {
|
||||
const input = createApartmentSchema.parse(request.body);
|
||||
|
||||
const apartment = await prisma.apartment.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
address: input.address ?? null,
|
||||
totalArea: input.totalArea ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201);
|
||||
return { data: toApartmentResponse(apartment) };
|
||||
});
|
||||
|
||||
// GET /api/apartments/:id
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/api/apartments/:id',
|
||||
async (request, reply): Promise<ApiResponse<ApartmentWithRooms>> => {
|
||||
const apartment = await prisma.apartment.findUnique({
|
||||
where: { id: request.params.id },
|
||||
include: {
|
||||
rooms: {
|
||||
select: { id: true, name: true, order: true },
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apartment) {
|
||||
return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...toApartmentResponse(apartment),
|
||||
rooms: apartment.rooms,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/apartments/:id
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/apartments/:id',
|
||||
async (request, reply): Promise<ApiResponse<Apartment>> => {
|
||||
const input = updateApartmentSchema.parse(request.body);
|
||||
|
||||
const existing = await prisma.apartment.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const apartment = await prisma.apartment.update({
|
||||
where: { id: request.params.id },
|
||||
data: {
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.address !== undefined && { address: input.address ?? null }),
|
||||
...(input.totalArea !== undefined && { totalArea: input.totalArea ?? null }),
|
||||
},
|
||||
});
|
||||
|
||||
return { data: toApartmentResponse(apartment) };
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/apartments/:id
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/api/apartments/:id',
|
||||
async (request, reply): Promise<void> => {
|
||||
const existing = await prisma.apartment.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.apartment.delete({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
reply.status(204);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function toApartmentResponse(apartment: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
totalArea: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Apartment {
|
||||
return {
|
||||
id: apartment.id,
|
||||
name: apartment.name,
|
||||
address: apartment.address,
|
||||
totalArea: apartment.totalArea,
|
||||
createdAt: apartment.createdAt.toISOString(),
|
||||
updatedAt: apartment.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export default apartmentRoutes;
|
||||
@@ -0,0 +1,564 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import {
|
||||
bulkUpdateWallsSchema,
|
||||
createWallOpeningSchema,
|
||||
updateWallOpeningSchema,
|
||||
createElectricalItemSchema,
|
||||
updateElectricalItemSchema,
|
||||
createFurnitureItemSchema,
|
||||
updateFurnitureItemSchema,
|
||||
batchSyncOpeningsSchema,
|
||||
batchSyncElectricalSchema,
|
||||
batchSyncFurnitureSchema,
|
||||
} from '@house-plan-maker/shared';
|
||||
import type {
|
||||
Wall,
|
||||
WallOpening,
|
||||
ElectricalItem,
|
||||
FurnitureItem,
|
||||
ApiResponse,
|
||||
ApiListResponse,
|
||||
} from '@house-plan-maker/shared';
|
||||
import {
|
||||
toWallResponse,
|
||||
toOpeningResponse,
|
||||
toElectricalResponse,
|
||||
toFurnitureResponse,
|
||||
} from '../utils/mappers.js';
|
||||
|
||||
const elementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
const { prisma } = fastify;
|
||||
|
||||
// ── Walls ──
|
||||
|
||||
// PUT /api/rooms/:id/walls — bulk replace all walls in a room
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/walls',
|
||||
async (request, reply): Promise<ApiResponse<readonly Wall[]>> => {
|
||||
const input = bulkUpdateWallsSchema.parse(request.body);
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const walls = await prisma.$transaction(async (tx) => {
|
||||
await tx.wall.deleteMany({ where: { roomId: request.params.id } });
|
||||
|
||||
const created = await Promise.all(
|
||||
input.walls.map((w) =>
|
||||
tx.wall.create({
|
||||
data: {
|
||||
roomId: request.params.id,
|
||||
startX: w.startX,
|
||||
startY: w.startY,
|
||||
endX: w.endX,
|
||||
endY: w.endY,
|
||||
thickness: w.thickness ?? 0.1,
|
||||
direction: w.direction ?? 'OTHER',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return { data: walls.map(toWallResponse) };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Wall Openings ──
|
||||
|
||||
// POST /api/rooms/:id/openings
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/openings',
|
||||
async (request, reply): Promise<ApiResponse<WallOpening>> => {
|
||||
const input = createWallOpeningSchema.parse(request.body);
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const wall = await prisma.wall.findUnique({
|
||||
where: { id: input.wallId },
|
||||
});
|
||||
|
||||
if (!wall || wall.roomId !== request.params.id) {
|
||||
return reply.status(400).send({ error: 'Wall not found in this room', statusCode: 400 });
|
||||
}
|
||||
|
||||
const opening = await prisma.wallOpening.create({
|
||||
data: {
|
||||
roomId: request.params.id,
|
||||
wallId: input.wallId,
|
||||
type: input.type,
|
||||
positionAlongWall: input.positionAlongWall,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
elevationFromFloor: input.elevationFromFloor ?? 0,
|
||||
openDirection: input.openDirection ?? 'LEFT',
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201);
|
||||
return { data: toOpeningResponse(opening) };
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/rooms/:id/openings/:openingId
|
||||
fastify.put<{ Params: { id: string; openingId: string } }>(
|
||||
'/api/rooms/:id/openings/:openingId',
|
||||
async (request, reply): Promise<ApiResponse<WallOpening>> => {
|
||||
const input = updateWallOpeningSchema.parse(request.body);
|
||||
|
||||
const existing = await prisma.wallOpening.findUnique({
|
||||
where: { id: request.params.openingId },
|
||||
});
|
||||
|
||||
if (!existing || existing.roomId !== request.params.id) {
|
||||
return reply.status(404).send({ error: 'Opening not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const opening = await prisma.wallOpening.update({
|
||||
where: { id: request.params.openingId },
|
||||
data: {
|
||||
...(input.type !== undefined && { type: input.type }),
|
||||
...(input.positionAlongWall !== undefined && { positionAlongWall: input.positionAlongWall }),
|
||||
...(input.width !== undefined && { width: input.width }),
|
||||
...(input.height !== undefined && { height: input.height }),
|
||||
...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor }),
|
||||
...(input.openDirection !== undefined && { openDirection: input.openDirection }),
|
||||
},
|
||||
});
|
||||
|
||||
return { data: toOpeningResponse(opening) };
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/rooms/:id/openings/:openingId
|
||||
fastify.delete<{ Params: { id: string; openingId: string } }>(
|
||||
'/api/rooms/:id/openings/:openingId',
|
||||
async (request, reply): Promise<void> => {
|
||||
const existing = await prisma.wallOpening.findUnique({
|
||||
where: { id: request.params.openingId },
|
||||
});
|
||||
|
||||
if (!existing || existing.roomId !== request.params.id) {
|
||||
return reply.status(404).send({ error: 'Opening not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.wallOpening.delete({
|
||||
where: { id: request.params.openingId },
|
||||
});
|
||||
|
||||
reply.status(204);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Electrical Items ──
|
||||
|
||||
// GET /api/rooms/:id/electrical
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/electrical',
|
||||
async (request, reply): Promise<ApiListResponse<ElectricalItem>> => {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const items = await prisma.electricalItem.findMany({
|
||||
where: { roomId: request.params.id },
|
||||
});
|
||||
|
||||
return { data: items.map(toElectricalResponse) };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/rooms/:id/electrical
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/electrical',
|
||||
async (request, reply): Promise<ApiResponse<ElectricalItem>> => {
|
||||
const input = createElectricalItemSchema.parse(request.body);
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const item = await prisma.electricalItem.create({
|
||||
data: {
|
||||
roomId: request.params.id,
|
||||
type: input.type,
|
||||
x: input.x,
|
||||
y: input.y,
|
||||
wallId: input.wallId ?? null,
|
||||
elevationFromFloor: input.elevationFromFloor ?? null,
|
||||
rotation: input.rotation ?? 0,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201);
|
||||
return { data: toElectricalResponse(item) };
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/electrical/:id
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/electrical/:id',
|
||||
async (request, reply): Promise<ApiResponse<ElectricalItem>> => {
|
||||
const input = updateElectricalItemSchema.parse(request.body);
|
||||
|
||||
const existing = await prisma.electricalItem.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Electrical item not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const item = await prisma.electricalItem.update({
|
||||
where: { id: request.params.id },
|
||||
data: {
|
||||
...(input.type !== undefined && { type: input.type }),
|
||||
...(input.x !== undefined && { x: input.x }),
|
||||
...(input.y !== undefined && { y: input.y }),
|
||||
...(input.wallId !== undefined && { wallId: input.wallId ?? null }),
|
||||
...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor ?? null }),
|
||||
...(input.rotation !== undefined && { rotation: input.rotation }),
|
||||
...(input.metadata !== undefined && {
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return { data: toElectricalResponse(item) };
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/electrical/:id
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/api/electrical/:id',
|
||||
async (request, reply): Promise<void> => {
|
||||
const existing = await prisma.electricalItem.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Electrical item not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.electricalItem.delete({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
reply.status(204);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Furniture Items ──
|
||||
|
||||
// GET /api/rooms/:id/furniture
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/furniture',
|
||||
async (request, reply): Promise<ApiListResponse<FurnitureItem>> => {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const items = await prisma.furnitureItem.findMany({
|
||||
where: { roomId: request.params.id },
|
||||
});
|
||||
|
||||
return { data: items.map(toFurnitureResponse) };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/rooms/:id/furniture
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/furniture',
|
||||
async (request, reply): Promise<ApiResponse<FurnitureItem>> => {
|
||||
const input = createFurnitureItemSchema.parse(request.body);
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const item = await prisma.furnitureItem.create({
|
||||
data: {
|
||||
roomId: request.params.id,
|
||||
type: input.type,
|
||||
x: input.x,
|
||||
y: input.y,
|
||||
width: input.width,
|
||||
depth: input.depth,
|
||||
height: input.height,
|
||||
rotation: input.rotation ?? 0,
|
||||
elevationFromFloor: input.elevationFromFloor ?? 0,
|
||||
label: input.label ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201);
|
||||
return { data: toFurnitureResponse(item) };
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/furniture/:id
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/furniture/:id',
|
||||
async (request, reply): Promise<ApiResponse<FurnitureItem>> => {
|
||||
const input = updateFurnitureItemSchema.parse(request.body);
|
||||
|
||||
const existing = await prisma.furnitureItem.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Furniture item not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const item = await prisma.furnitureItem.update({
|
||||
where: { id: request.params.id },
|
||||
data: {
|
||||
...(input.type !== undefined && { type: input.type }),
|
||||
...(input.x !== undefined && { x: input.x }),
|
||||
...(input.y !== undefined && { y: input.y }),
|
||||
...(input.width !== undefined && { width: input.width }),
|
||||
...(input.depth !== undefined && { depth: input.depth }),
|
||||
...(input.height !== undefined && { height: input.height }),
|
||||
...(input.rotation !== undefined && { rotation: input.rotation }),
|
||||
...(input.elevationFromFloor !== undefined && { elevationFromFloor: input.elevationFromFloor }),
|
||||
...(input.label !== undefined && { label: input.label ?? null }),
|
||||
},
|
||||
});
|
||||
|
||||
return { data: toFurnitureResponse(item) };
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/furniture/:id
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/api/furniture/:id',
|
||||
async (request, reply): Promise<void> => {
|
||||
const existing = await prisma.furnitureItem.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Furniture item not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.furnitureItem.delete({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
reply.status(204);
|
||||
},
|
||||
);
|
||||
// ── Batch Sync: Openings ──
|
||||
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/openings/batch',
|
||||
async (request, reply): Promise<ApiListResponse<WallOpening>> => {
|
||||
const input = batchSyncOpeningsSchema.parse(request.body);
|
||||
const roomId = request.params.id;
|
||||
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Deletes
|
||||
if (input.delete.length > 0) {
|
||||
await tx.wallOpening.deleteMany({
|
||||
where: { id: { in: input.delete }, roomId },
|
||||
});
|
||||
}
|
||||
|
||||
// Updates
|
||||
for (const item of input.update) {
|
||||
await tx.wallOpening.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
...(item.data.type !== undefined && { type: item.data.type }),
|
||||
...(item.data.positionAlongWall !== undefined && { positionAlongWall: item.data.positionAlongWall }),
|
||||
...(item.data.width !== undefined && { width: item.data.width }),
|
||||
...(item.data.height !== undefined && { height: item.data.height }),
|
||||
...(item.data.elevationFromFloor !== undefined && { elevationFromFloor: item.data.elevationFromFloor }),
|
||||
...(item.data.openDirection !== undefined && { openDirection: item.data.openDirection }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Creates
|
||||
for (const item of input.create) {
|
||||
await tx.wallOpening.create({
|
||||
data: {
|
||||
roomId,
|
||||
wallId: item.wallId,
|
||||
type: item.type,
|
||||
positionAlongWall: item.positionAlongWall,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
elevationFromFloor: item.elevationFromFloor ?? 0,
|
||||
openDirection: item.openDirection ?? 'LEFT',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const allOpenings = await prisma.wallOpening.findMany({ where: { roomId } });
|
||||
return { data: allOpenings.map(toOpeningResponse) };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Batch Sync: Electrical ──
|
||||
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/electrical/batch',
|
||||
async (request, reply): Promise<ApiListResponse<ElectricalItem>> => {
|
||||
const input = batchSyncElectricalSchema.parse(request.body);
|
||||
const roomId = request.params.id;
|
||||
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Deletes
|
||||
if (input.delete.length > 0) {
|
||||
await tx.electricalItem.deleteMany({
|
||||
where: { id: { in: input.delete }, roomId },
|
||||
});
|
||||
}
|
||||
|
||||
// Updates
|
||||
for (const item of input.update) {
|
||||
await tx.electricalItem.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
...(item.data.type !== undefined && { type: item.data.type }),
|
||||
...(item.data.x !== undefined && { x: item.data.x }),
|
||||
...(item.data.y !== undefined && { y: item.data.y }),
|
||||
...(item.data.wallId !== undefined && { wallId: item.data.wallId ?? null }),
|
||||
...(item.data.elevationFromFloor !== undefined && { elevationFromFloor: item.data.elevationFromFloor ?? null }),
|
||||
...(item.data.rotation !== undefined && { rotation: item.data.rotation }),
|
||||
...(item.data.metadata !== undefined && {
|
||||
metadata: item.data.metadata ? JSON.stringify(item.data.metadata) : null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Creates
|
||||
for (const item of input.create) {
|
||||
await tx.electricalItem.create({
|
||||
data: {
|
||||
roomId,
|
||||
type: item.type,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
wallId: item.wallId ?? null,
|
||||
elevationFromFloor: item.elevationFromFloor ?? null,
|
||||
rotation: item.rotation ?? 0,
|
||||
metadata: item.metadata ? JSON.stringify(item.metadata) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const allItems = await prisma.electricalItem.findMany({ where: { roomId } });
|
||||
return { data: allItems.map(toElectricalResponse) };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Batch Sync: Furniture ──
|
||||
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/furniture/batch',
|
||||
async (request, reply): Promise<ApiListResponse<FurnitureItem>> => {
|
||||
const input = batchSyncFurnitureSchema.parse(request.body);
|
||||
const roomId = request.params.id;
|
||||
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Deletes
|
||||
if (input.delete.length > 0) {
|
||||
await tx.furnitureItem.deleteMany({
|
||||
where: { id: { in: input.delete }, roomId },
|
||||
});
|
||||
}
|
||||
|
||||
// Updates
|
||||
for (const item of input.update) {
|
||||
await tx.furnitureItem.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
...(item.data.type !== undefined && { type: item.data.type }),
|
||||
...(item.data.x !== undefined && { x: item.data.x }),
|
||||
...(item.data.y !== undefined && { y: item.data.y }),
|
||||
...(item.data.width !== undefined && { width: item.data.width }),
|
||||
...(item.data.depth !== undefined && { depth: item.data.depth }),
|
||||
...(item.data.height !== undefined && { height: item.data.height }),
|
||||
...(item.data.rotation !== undefined && { rotation: item.data.rotation }),
|
||||
...(item.data.elevationFromFloor !== undefined && { elevationFromFloor: item.data.elevationFromFloor }),
|
||||
...(item.data.label !== undefined && { label: item.data.label ?? null }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Creates
|
||||
for (const item of input.create) {
|
||||
await tx.furnitureItem.create({
|
||||
data: {
|
||||
roomId,
|
||||
type: item.type,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
width: item.width,
|
||||
depth: item.depth,
|
||||
height: item.height,
|
||||
rotation: item.rotation ?? 0,
|
||||
elevationFromFloor: item.elevationFromFloor ?? 0,
|
||||
label: item.label ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const allItems = await prisma.furnitureItem.findMany({ where: { roomId } });
|
||||
return { data: allItems.map(toFurnitureResponse) };
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default elementRoutes;
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import {
|
||||
createRoomSchema,
|
||||
updateRoomSchema,
|
||||
} from '@house-plan-maker/shared';
|
||||
import type {
|
||||
Room,
|
||||
RoomFull,
|
||||
Point,
|
||||
ApiResponse,
|
||||
ApiListResponse,
|
||||
} from '@house-plan-maker/shared';
|
||||
import {
|
||||
safeJsonParse,
|
||||
toWallResponse,
|
||||
toOpeningResponse,
|
||||
toElectricalResponse,
|
||||
toFurnitureResponse,
|
||||
} from '../utils/mappers.js';
|
||||
|
||||
const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
const { prisma } = fastify;
|
||||
|
||||
// GET /api/apartments/:id/rooms
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/api/apartments/:id/rooms',
|
||||
async (request, reply): Promise<ApiListResponse<Room>> => {
|
||||
const apartment = await prisma.apartment.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!apartment) {
|
||||
return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const rooms = await prisma.room.findMany({
|
||||
where: { apartmentId: request.params.id },
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
|
||||
return { data: rooms.map(toRoomResponse) };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/apartments/:id/rooms
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/api/apartments/:id/rooms',
|
||||
async (request, reply): Promise<ApiResponse<Room>> => {
|
||||
const input = createRoomSchema.parse(request.body);
|
||||
|
||||
const apartment = await prisma.apartment.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!apartment) {
|
||||
return reply.status(404).send({ error: 'Apartment not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const room = await prisma.room.create({
|
||||
data: {
|
||||
apartmentId: request.params.id,
|
||||
name: input.name,
|
||||
shape: JSON.stringify(input.shape ?? []),
|
||||
width: input.width ?? null,
|
||||
height: input.height ?? null,
|
||||
wallHeight: input.wallHeight ?? 2.7,
|
||||
plinthHeight: input.plinthHeight ?? 0.06,
|
||||
plinthThickness: input.plinthThickness ?? 0.01,
|
||||
order: input.order ?? 0,
|
||||
// posX/posY added in schema migration; client regeneration required
|
||||
...(input.posX !== undefined && { posX: input.posX }),
|
||||
...(input.posY !== undefined && { posY: input.posY }),
|
||||
...(input.floorType !== undefined && { floorType: input.floorType }),
|
||||
...(input.wallColor !== undefined && { wallColor: input.wallColor }),
|
||||
} as Parameters<typeof prisma.room.create>[0]['data'],
|
||||
});
|
||||
|
||||
reply.status(201);
|
||||
return { data: toRoomResponse(room) };
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/rooms/:id
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id',
|
||||
async (request, reply): Promise<ApiResponse<Room>> => {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
return { data: toRoomResponse(room) };
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/rooms/:id/full
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id/full',
|
||||
async (request, reply): Promise<ApiResponse<RoomFull>> => {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
include: {
|
||||
walls: true,
|
||||
openings: true,
|
||||
electricalItems: true,
|
||||
furnitureItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...toRoomResponse(room),
|
||||
walls: room.walls.map(toWallResponse),
|
||||
openings: room.openings.map(toOpeningResponse),
|
||||
electricalItems: room.electricalItems.map(toElectricalResponse),
|
||||
furnitureItems: room.furnitureItems.map(toFurnitureResponse),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/rooms/:id
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id',
|
||||
async (request, reply): Promise<ApiResponse<Room>> => {
|
||||
const input = updateRoomSchema.parse(request.body);
|
||||
|
||||
const existing = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const room = await prisma.room.update({
|
||||
where: { id: request.params.id },
|
||||
data: {
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.shape !== undefined && { shape: JSON.stringify(input.shape) }),
|
||||
...(input.width !== undefined && { width: input.width ?? null }),
|
||||
...(input.height !== undefined && { height: input.height ?? null }),
|
||||
...(input.wallHeight !== undefined && { wallHeight: input.wallHeight }),
|
||||
...(input.plinthHeight !== undefined && { plinthHeight: input.plinthHeight }),
|
||||
...(input.plinthThickness !== undefined && { plinthThickness: input.plinthThickness }),
|
||||
...(input.order !== undefined && { order: input.order }),
|
||||
...(input.posX !== undefined && { posX: input.posX }),
|
||||
...(input.posY !== undefined && { posY: input.posY }),
|
||||
...(input.floorType !== undefined && { floorType: input.floorType }),
|
||||
...(input.wallColor !== undefined && { wallColor: input.wallColor }),
|
||||
},
|
||||
});
|
||||
|
||||
return { data: toRoomResponse(room) };
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/rooms/:id
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/api/rooms/:id',
|
||||
async (request, reply): Promise<void> => {
|
||||
const existing = await prisma.room.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: 'Room not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
await prisma.room.delete({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
reply.status(204);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function toRoomResponse(room: {
|
||||
id: string;
|
||||
apartmentId: string;
|
||||
name: string;
|
||||
shape: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
wallHeight: number;
|
||||
plinthHeight: number;
|
||||
plinthThickness: number;
|
||||
order: number;
|
||||
posX?: number | null;
|
||||
posY?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Room {
|
||||
return {
|
||||
id: room.id,
|
||||
apartmentId: room.apartmentId,
|
||||
name: room.name,
|
||||
shape: safeJsonParse<Point[]>(room.shape, []),
|
||||
width: room.width,
|
||||
height: room.height,
|
||||
wallHeight: room.wallHeight,
|
||||
plinthHeight: room.plinthHeight,
|
||||
plinthThickness: room.plinthThickness,
|
||||
order: room.order,
|
||||
posX: room.posX ?? 0,
|
||||
posY: room.posY ?? 0,
|
||||
floorType: (((room as Record<string, unknown>).floorType as string) ?? 'CONCRETE') as Room['floorType'],
|
||||
wallColor: ((room as Record<string, unknown>).wallColor as string) ?? '#f5f0eb',
|
||||
createdAt: room.createdAt.toISOString(),
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export default roomRoutes;
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
WALL_DIRECTIONS,
|
||||
OPENING_TYPES,
|
||||
DOOR_OPEN_DIRECTIONS,
|
||||
ELECTRICAL_TYPES,
|
||||
FURNITURE_TYPES,
|
||||
} from '@house-plan-maker/shared';
|
||||
import type {
|
||||
Wall,
|
||||
WallOpening,
|
||||
ElectricalItem,
|
||||
FurnitureItem,
|
||||
WallDirection,
|
||||
OpeningType,
|
||||
DoorOpenDirection,
|
||||
ElectricalType,
|
||||
FurnitureType,
|
||||
} from '@house-plan-maker/shared';
|
||||
|
||||
/**
|
||||
* Parse a JSON string, returning the fallback on failure.
|
||||
* The fallback type is independent of T so that `null` can be passed.
|
||||
*/
|
||||
export function safeJsonParse<T, F = T>(value: string, fallback: F): T | F {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function validateEnum<T extends string>(
|
||||
value: string,
|
||||
allowed: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
return (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
|
||||
}
|
||||
|
||||
export function toWallResponse(wall: {
|
||||
id: string;
|
||||
roomId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
thickness: number;
|
||||
direction: string;
|
||||
}): Wall {
|
||||
return {
|
||||
id: wall.id,
|
||||
roomId: wall.roomId,
|
||||
startX: wall.startX,
|
||||
startY: wall.startY,
|
||||
endX: wall.endX,
|
||||
endY: wall.endY,
|
||||
thickness: wall.thickness,
|
||||
direction: validateEnum<WallDirection>(wall.direction, WALL_DIRECTIONS, 'OTHER'),
|
||||
};
|
||||
}
|
||||
|
||||
export function toOpeningResponse(opening: {
|
||||
id: string;
|
||||
roomId: string;
|
||||
wallId: string;
|
||||
type: string;
|
||||
positionAlongWall: number;
|
||||
width: number;
|
||||
height: number;
|
||||
elevationFromFloor: number;
|
||||
openDirection: string;
|
||||
}): WallOpening {
|
||||
return {
|
||||
id: opening.id,
|
||||
roomId: opening.roomId,
|
||||
wallId: opening.wallId,
|
||||
type: validateEnum<OpeningType>(opening.type, OPENING_TYPES, 'DOOR'),
|
||||
positionAlongWall: opening.positionAlongWall,
|
||||
width: opening.width,
|
||||
height: opening.height,
|
||||
elevationFromFloor: opening.elevationFromFloor,
|
||||
openDirection: validateEnum<DoorOpenDirection>(opening.openDirection, DOOR_OPEN_DIRECTIONS, 'LEFT'),
|
||||
};
|
||||
}
|
||||
|
||||
export function toElectricalResponse(item: {
|
||||
id: string;
|
||||
roomId: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
wallId: string | null;
|
||||
elevationFromFloor: number | null;
|
||||
rotation: number;
|
||||
metadata: string | null;
|
||||
}): ElectricalItem {
|
||||
return {
|
||||
id: item.id,
|
||||
roomId: item.roomId,
|
||||
type: validateEnum<ElectricalType>(item.type, ELECTRICAL_TYPES, 'OUTLET'),
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
wallId: item.wallId,
|
||||
elevationFromFloor: item.elevationFromFloor,
|
||||
rotation: item.rotation,
|
||||
metadata: item.metadata ? safeJsonParse<Record<string, unknown>, null>(item.metadata, null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toFurnitureResponse(item: {
|
||||
id: string;
|
||||
roomId: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
elevationFromFloor?: number | null;
|
||||
label: string | null;
|
||||
}): FurnitureItem {
|
||||
return {
|
||||
id: item.id,
|
||||
roomId: item.roomId,
|
||||
type: validateEnum<FurnitureType>(item.type, FURNITURE_TYPES, 'OTHER'),
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
width: item.width,
|
||||
depth: item.depth,
|
||||
height: item.height,
|
||||
rotation: item.rotation,
|
||||
elevationFromFloor: item.elevationFromFloor ?? 0,
|
||||
label: item.label,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user