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
+6
View File
@@ -0,0 +1,6 @@
# Server configuration
PORT=3001
HOST=0.0.0.0
# Database
DATABASE_URL="file:./dev.db"
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@house-plan-maker/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --build",
"start": "node dist/index.js",
"test": "vitest run",
"lint": "eslint src/"
},
"dependencies": {
"@house-plan-maker/shared": "*",
"@prisma/client": "^6.3.0",
"fastify": "^5.2.0",
"fastify-plugin": "^5.1.0",
"zod": "^4.3.6"
},
"devDependencies": {
"prisma": "^6.3.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
@@ -0,0 +1,102 @@
-- CreateTable
CREATE TABLE "Apartment" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"address" TEXT,
"totalArea" REAL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Room" (
"id" TEXT NOT NULL PRIMARY KEY,
"apartmentId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"shape" TEXT NOT NULL DEFAULT '[]',
"width" REAL,
"height" REAL,
"wallHeight" REAL NOT NULL DEFAULT 2.7,
"plinthHeight" REAL NOT NULL DEFAULT 0.06,
"plinthThickness" REAL NOT NULL DEFAULT 0.01,
"order" INTEGER NOT NULL DEFAULT 0,
"posX" REAL NOT NULL DEFAULT 0,
"posY" REAL NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Room_apartmentId_fkey" FOREIGN KEY ("apartmentId") REFERENCES "Apartment" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Wall" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"startX" REAL NOT NULL,
"startY" REAL NOT NULL,
"endX" REAL NOT NULL,
"endY" REAL NOT NULL,
"thickness" REAL NOT NULL DEFAULT 0.1,
"direction" TEXT NOT NULL DEFAULT 'OTHER',
CONSTRAINT "Wall_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WallOpening" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"wallId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"positionAlongWall" REAL NOT NULL,
"width" REAL NOT NULL,
"height" REAL NOT NULL,
"elevationFromFloor" REAL NOT NULL DEFAULT 0,
CONSTRAINT "WallOpening_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WallOpening_wallId_fkey" FOREIGN KEY ("wallId") REFERENCES "Wall" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ElectricalItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"x" REAL NOT NULL,
"y" REAL NOT NULL,
"wallId" TEXT,
"elevationFromFloor" REAL,
"rotation" REAL NOT NULL DEFAULT 0,
"metadata" TEXT,
CONSTRAINT "ElectricalItem_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "FurnitureItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"x" REAL NOT NULL,
"y" REAL NOT NULL,
"width" REAL NOT NULL,
"depth" REAL NOT NULL,
"height" REAL NOT NULL,
"rotation" REAL NOT NULL DEFAULT 0,
"label" TEXT,
CONSTRAINT "FurnitureItem_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "Room_apartmentId_idx" ON "Room"("apartmentId");
-- CreateIndex
CREATE INDEX "Wall_roomId_idx" ON "Wall"("roomId");
-- CreateIndex
CREATE INDEX "WallOpening_roomId_idx" ON "WallOpening"("roomId");
-- CreateIndex
CREATE INDEX "WallOpening_wallId_idx" ON "WallOpening"("wallId");
-- CreateIndex
CREATE INDEX "ElectricalItem_roomId_idx" ON "ElectricalItem"("roomId");
-- CreateIndex
CREATE INDEX "FurnitureItem_roomId_idx" ON "FurnitureItem"("roomId");
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+108
View File
@@ -0,0 +1,108 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Apartment {
id String @id @default(cuid())
name String
address String?
totalArea Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rooms Room[]
}
model Room {
id String @id @default(cuid())
apartmentId String
name String
shape String @default("[]") // JSON array of {x, y} points
width Float?
height Float?
wallHeight Float @default(2.7)
plinthHeight Float @default(0.06)
plinthThickness Float @default(0.01)
order Int @default(0)
posX Float @default(0)
posY Float @default(0)
floorType String @default("CONCRETE")
wallColor String @default("#f5f0eb")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apartment Apartment @relation(fields: [apartmentId], references: [id], onDelete: Cascade)
walls Wall[]
openings WallOpening[]
electricalItems ElectricalItem[]
furnitureItems FurnitureItem[]
@@index([apartmentId])
}
model Wall {
id String @id @default(cuid())
roomId String
startX Float
startY Float
endX Float
endY Float
thickness Float @default(0.1)
direction String @default("OTHER") // NORTH, SOUTH, EAST, WEST, OTHER
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
openings WallOpening[]
@@index([roomId])
}
model WallOpening {
id String @id @default(cuid())
roomId String
wallId String
type String // DOOR, WINDOW
positionAlongWall Float
width Float
height Float
elevationFromFloor Float @default(0)
openDirection String @default("LEFT") // LEFT, RIGHT, INWARD, OUTWARD
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
wall Wall @relation(fields: [wallId], references: [id], onDelete: Cascade)
@@index([roomId])
@@index([wallId])
}
model ElectricalItem {
id String @id @default(cuid())
roomId String
type String // OUTLET, SWITCH, JUNCTION_BOX, LIGHT_CEILING, LIGHT_WALL, CABLE_ROUTE
x Float
y Float
wallId String?
elevationFromFloor Float?
rotation Float @default(0)
metadata String? // JSON
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@index([roomId])
}
model FurnitureItem {
id String @id @default(cuid())
roomId String
type String // BED, DESK, WARDROBE, SOFA, TABLE, CHAIR, SHELF, NIGHTSTAND, DRESSER, BOOKCASE, OTHER
x Float
y Float
width Float
depth Float
height Float
rotation Float @default(0)
elevationFromFloor Float @default(0)
label String?
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@index([roomId])
}
+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,
};
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../packages/shared" }
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
passWithNoTests: true,
},
});