Persist project locations
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
|
||||
@@ -11,26 +10,4 @@ export class FloorRepository {
|
||||
.where(eq(floors.projectId, projectId))
|
||||
.orderBy(asc(floors.sortOrder), asc(floors.name));
|
||||
}
|
||||
|
||||
async create(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const existing = await this.listByProject(projectId);
|
||||
const floor = {
|
||||
id,
|
||||
projectId,
|
||||
name,
|
||||
sortOrder: existing.length,
|
||||
};
|
||||
await db.insert(floors).values(floor);
|
||||
return floor;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, floorId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(and(eq(floors.projectId, projectId), eq(floors.id, floorId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertProjectFloorDeleteProjectCommand,
|
||||
assertProjectFloorInsertProjectCommand,
|
||||
assertProjectRoomDeleteProjectCommand,
|
||||
assertProjectRoomInsertProjectCommand,
|
||||
createProjectFloorDeleteProjectCommand,
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectRoomDeleteProjectCommand,
|
||||
createProjectRoomInsertProjectCommand,
|
||||
projectFloorDeleteCommandType,
|
||||
projectFloorInsertCommandType,
|
||||
projectRoomDeleteCommandType,
|
||||
projectRoomInsertCommandType,
|
||||
type ProjectFloorSnapshot,
|
||||
type ProjectLocationStructureProjectCommand,
|
||||
type ProjectRoomSnapshot,
|
||||
} from "../../domain/models/project-location-structure-project-command.model.js";
|
||||
import type {
|
||||
ExecuteProjectLocationStructureCommandInput,
|
||||
ProjectLocationStructureProjectCommandStore,
|
||||
} from "../../domain/ports/project-location-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
export class ProjectLocationStructureProjectCommandRepository
|
||||
implements ProjectLocationStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteProjectLocationStructureCommandInput) {
|
||||
return this.database.transaction((tx) => {
|
||||
const inverse = this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.command
|
||||
);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ProjectLocationStructureProjectCommand
|
||||
): ProjectLocationStructureProjectCommand {
|
||||
switch (command.type) {
|
||||
case projectFloorInsertCommandType:
|
||||
assertProjectFloorInsertProjectCommand(command);
|
||||
return this.insertFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
);
|
||||
case projectFloorDeleteCommandType:
|
||||
assertProjectFloorDeleteProjectCommand(command);
|
||||
return this.deleteFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
);
|
||||
case projectRoomInsertCommandType:
|
||||
assertProjectRoomInsertProjectCommand(command);
|
||||
return this.insertRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
);
|
||||
case projectRoomDeleteCommandType:
|
||||
assertProjectRoomDeleteProjectCommand(command);
|
||||
return this.deleteRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private insertFloor(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
floor: ProjectFloorSnapshot
|
||||
) {
|
||||
this.assertProjectExists(database, projectId);
|
||||
if (floor.projectId !== projectId) {
|
||||
throw new Error("Project-floor snapshot does not belong to project.");
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(eq(floors.id, floor.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project-floor id already exists.");
|
||||
}
|
||||
database.insert(floors).values(floor).run();
|
||||
return createProjectFloorDeleteProjectCommand(floor);
|
||||
}
|
||||
|
||||
private deleteFloor(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectFloorSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, expected.id),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project floor changed before deletion.");
|
||||
}
|
||||
const referencedRoom = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(eq(rooms.floorId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedRoom) {
|
||||
throw new Error(
|
||||
"A project floor with assigned rooms cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, expected.id),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Project floor could not be deleted.");
|
||||
}
|
||||
return createProjectFloorInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private insertRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
room: ProjectRoomSnapshot
|
||||
) {
|
||||
this.assertProjectExists(database, projectId);
|
||||
if (room.projectId !== projectId) {
|
||||
throw new Error("Project-room snapshot does not belong to project.");
|
||||
}
|
||||
if (room.floorId) {
|
||||
const floor = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, room.floorId),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!floor) {
|
||||
throw new Error("Project room references a foreign floor.");
|
||||
}
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, room.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project-room id already exists.");
|
||||
}
|
||||
database.insert(rooms).values(room).run();
|
||||
return createProjectRoomDeleteProjectCommand(room);
|
||||
}
|
||||
|
||||
private deleteRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectRoomSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, expected.id),
|
||||
eq(rooms.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project room changed before deletion.");
|
||||
}
|
||||
const referencedDeviceRow = database
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.roomId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
const referencedLegacyConsumer = database
|
||||
.select({ id: consumers.id })
|
||||
.from(consumers)
|
||||
.where(eq(consumers.roomId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedDeviceRow || referencedLegacyConsumer) {
|
||||
throw new Error(
|
||||
"A referenced project room cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, expected.id),
|
||||
eq(rooms.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Project room could not be deleted.");
|
||||
}
|
||||
return createProjectRoomInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private assertProjectExists(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
) {
|
||||
const project = database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project does not exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
expected: object,
|
||||
actual: object
|
||||
) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(([key, value]) => actualRecord[key] === value)
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js";
|
||||
|
||||
export class RoomRepository {
|
||||
async listByProject(projectId: string) {
|
||||
@@ -12,31 +10,4 @@ export class RoomRepository {
|
||||
.where(eq(rooms.projectId, projectId))
|
||||
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName));
|
||||
}
|
||||
|
||||
async create(projectId: string, input: CreateRoomInput) {
|
||||
const id = crypto.randomUUID();
|
||||
const room = {
|
||||
id,
|
||||
projectId,
|
||||
floorId: input.floorId ?? null,
|
||||
roomNumber: input.roomNumber,
|
||||
roomName: input.roomName,
|
||||
};
|
||||
await db.insert(rooms).values(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, roomId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async findById(roomId: string) {
|
||||
const [row] = await db.select().from(rooms).where(eq(rooms.id, roomId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user