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:
2026-04-05 22:34:03 +03:00
parent b84807bbdb
commit af8b9fe00f
188 changed files with 35795 additions and 0 deletions
+42
View File
@@ -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();
+49
View File
@@ -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' });
+23
View File
@@ -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' });
+140
View File
@@ -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;
+564
View File
@@ -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;
+222
View File
@@ -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;
+136
View File
@@ -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,
};
}