feat(properties): add parent_id for sub-property hierarchy

Phase 1 of the parent/child rollup feature. Self-FK on properties
with ON DELETE RESTRICT, plus a CHECK that blocks self-reference at
the DB level. Service-layer helpers (getDescendantIds,
getAncestorIds, assertNoCycle) walk the tree via recursive CTEs and
guard against cycles and cross-company parents. softDeleteProperty
now refuses to delete a property with live children.

No UI yet — readers and roll-up routes land in Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:49:04 +07:00
parent 76248c3d7f
commit 8117253841
6 changed files with 4730 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "properties" ADD COLUMN "parent_id" uuid;--> statement-breakpoint
ALTER TABLE "properties" ADD CONSTRAINT "properties_parent_id_properties_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."properties"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "properties_by_parent" ON "properties" USING btree ("company_id","parent_id");
+7
View File
@@ -0,0 +1,7 @@
-- A property cannot be its own parent. Deeper cycle prevention (e.g.
-- A→B→A) is enforced in the service layer because Postgres can't express
-- recursive CHECK constraints; the assertNoCycle helper in
-- src/lib/server/services/properties.ts walks ancestors before save.
ALTER TABLE "properties"
ADD CONSTRAINT "properties_parent_not_self"
CHECK (parent_id IS NULL OR parent_id <> id);
File diff suppressed because it is too large Load Diff
+14
View File
@@ -113,6 +113,20 @@
"when": 1776932900000, "when": 1776932900000,
"tag": "0015_expenses_updated_at_trigger", "tag": "0015_expenses_updated_at_trigger",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1777268853483,
"tag": "0016_property_parent_id",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1777268853484,
"tag": "0017_property_parent_check",
"breakpoints": true
} }
] ]
} }
+10 -1
View File
@@ -1,3 +1,4 @@
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { pgTable, varchar, text, numeric, index } from 'drizzle-orm/pg-core'; import { pgTable, varchar, text, numeric, index } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy'; import { companies, users } from './tenancy';
import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared'; import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
@@ -9,6 +10,13 @@ export const properties = pgTable(
companyId: fk('company_id') companyId: fk('company_id')
.notNull() .notNull()
.references(() => companies.id, { onDelete: 'cascade' }), .references(() => companies.id, { onDelete: 'cascade' }),
// NULL = root. References another property in the same company. Use cases:
// a building owns its apartments, a campus owns its sub-buildings.
// Cross-company references and cycles are blocked at the service layer
// (see assertNoCycle / setParent in services/properties.ts).
parentId: fk('parent_id').references((): AnyPgColumn => properties.id, {
onDelete: 'restrict'
}),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
kind: varchar('kind', { length: 64 }), // warehouse, office, datacenter, ... kind: varchar('kind', { length: 64 }), // warehouse, office, datacenter, ...
addressLine1: varchar('address_line1', { length: 255 }), addressLine1: varchar('address_line1', { length: 255 }),
@@ -26,7 +34,8 @@ export const properties = pgTable(
deletedAt: deletedAt() deletedAt: deletedAt()
}, },
(t) => ({ (t) => ({
byCompany: index('properties_by_company').on(t.companyId) byCompany: index('properties_by_company').on(t.companyId),
byParent: index('properties_by_parent').on(t.companyId, t.parentId)
}) })
); );
+114
View File
@@ -7,6 +7,7 @@ export interface PropertyCreateInput {
createdBy: string; createdBy: string;
name: string; name: string;
kind?: string | null; kind?: string | null;
parentId?: string | null;
addressLine1?: string | null; addressLine1?: string | null;
addressLine2?: string | null; addressLine2?: string | null;
city?: string | null; city?: string | null;
@@ -16,9 +17,28 @@ export interface PropertyCreateInput {
notes?: string | null; notes?: string | null;
} }
async function assertParentInCompany(companyId: string, parentId: string): Promise<void> {
const [row] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.id, parentId),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (!row) throw new Error('parent property not found in this company');
}
export async function createProperty(input: PropertyCreateInput): Promise<{ id: string }> { export async function createProperty(input: PropertyCreateInput): Promise<{ id: string }> {
if (input.parentId) {
await assertParentInCompany(input.companyId, input.parentId);
}
const values: NewProperty = { const values: NewProperty = {
companyId: input.companyId, companyId: input.companyId,
parentId: input.parentId ?? null,
name: input.name.trim(), name: input.name.trim(),
kind: input.kind ?? null, kind: input.kind ?? null,
addressLine1: input.addressLine1 ?? null, addressLine1: input.addressLine1 ?? null,
@@ -62,11 +82,17 @@ export async function updateProperty(
id: string, id: string,
patch: Partial<PropertyCreateInput> patch: Partial<PropertyCreateInput>
): Promise<void> { ): Promise<void> {
if (patch.parentId !== undefined && patch.parentId !== null) {
if (patch.parentId === id) throw new Error('a property cannot be its own parent');
await assertParentInCompany(companyId, patch.parentId);
await assertNoCycle(companyId, id, patch.parentId);
}
await db await db
.update(properties) .update(properties)
.set({ .set({
...(patch.name !== undefined && { name: patch.name.trim() }), ...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.kind !== undefined && { kind: patch.kind ?? null }), ...(patch.kind !== undefined && { kind: patch.kind ?? null }),
...(patch.parentId !== undefined && { parentId: patch.parentId ?? null }),
...(patch.addressLine1 !== undefined && { addressLine1: patch.addressLine1 ?? null }), ...(patch.addressLine1 !== undefined && { addressLine1: patch.addressLine1 ?? null }),
...(patch.addressLine2 !== undefined && { addressLine2: patch.addressLine2 ?? null }), ...(patch.addressLine2 !== undefined && { addressLine2: patch.addressLine2 ?? null }),
...(patch.city !== undefined && { city: patch.city ?? null }), ...(patch.city !== undefined && { city: patch.city ?? null }),
@@ -81,8 +107,96 @@ export async function updateProperty(
} }
export async function softDeleteProperty(companyId: string, id: string): Promise<void> { export async function softDeleteProperty(companyId: string, id: string): Promise<void> {
// Block delete when live children exist — orphaning to root would silently
// disconnect roll-up reports. Caller should detach children first if that's
// the intent.
const [child] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.parentId, id),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (child) throw new Error('detach or delete sub-properties first');
await db await db
.update(properties) .update(properties)
.set({ deletedAt: sql`now()` }) .set({ deletedAt: sql`now()` })
.where(and(eq(properties.id, id), eq(properties.companyId, companyId))); .where(and(eq(properties.id, id), eq(properties.companyId, companyId)));
} }
// --- Hierarchy helpers -------------------------------------------------------
/**
* Returns [propertyId, ...all descendants] using a recursive CTE. The result
* always includes the root id so callers can use it directly with
* `WHERE property_id = ANY($ids)` for roll-up queries.
*
* Bounded by `deleted_at IS NULL` and `company_id = $companyId` to avoid
* leaking cross-company rows even if a service caller goofs.
*/
export async function getDescendantIds(
companyId: string,
propertyId: string
): Promise<string[]> {
const rows = await db.execute<{ id: string }>(sql`
WITH RECURSIVE tree AS (
SELECT id FROM properties
WHERE id = ${propertyId}
AND company_id = ${companyId}
AND deleted_at IS NULL
UNION ALL
SELECT p.id FROM properties p
INNER JOIN tree t ON p.parent_id = t.id
WHERE p.company_id = ${companyId}
AND p.deleted_at IS NULL
)
SELECT id FROM tree
`);
return rows.rows.map((r) => r.id);
}
/**
* Walk parent_id from `propertyId` up to the root. Used by assertNoCycle
* to detect attempts at moving a property under one of its own descendants.
*/
export async function getAncestorIds(
companyId: string,
propertyId: string
): Promise<string[]> {
const rows = await db.execute<{ id: string }>(sql`
WITH RECURSIVE chain AS (
SELECT id, parent_id FROM properties
WHERE id = ${propertyId} AND company_id = ${companyId}
UNION ALL
SELECT p.id, p.parent_id FROM properties p
INNER JOIN chain c ON p.id = c.parent_id
WHERE p.company_id = ${companyId}
)
SELECT id FROM chain WHERE id <> ${propertyId}
`);
return rows.rows.map((r) => r.id);
}
/**
* Throws if assigning `candidateParentId` as the parent of `propertyId` would
* create a cycle (i.e. candidateParentId is propertyId itself or one of its
* descendants).
*/
export async function assertNoCycle(
companyId: string,
propertyId: string,
candidateParentId: string
): Promise<void> {
if (candidateParentId === propertyId) {
throw new Error('a property cannot be its own parent');
}
const descendants = await getDescendantIds(companyId, propertyId);
if (descendants.includes(candidateParentId)) {
throw new Error('cannot set parent: that property is already a descendant');
}
}