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:
@@ -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");
|
||||
@@ -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
@@ -113,6 +113,20 @@
|
||||
"when": 1776932900000,
|
||||
"tag": "0015_expenses_updated_at_trigger",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, varchar, text, numeric, index } from 'drizzle-orm/pg-core';
|
||||
import { companies, users } from './tenancy';
|
||||
import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
|
||||
@@ -9,6 +10,13 @@ export const properties = pgTable(
|
||||
companyId: fk('company_id')
|
||||
.notNull()
|
||||
.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(),
|
||||
kind: varchar('kind', { length: 64 }), // warehouse, office, datacenter, ...
|
||||
addressLine1: varchar('address_line1', { length: 255 }),
|
||||
@@ -26,7 +34,8 @@ export const properties = pgTable(
|
||||
deletedAt: deletedAt()
|
||||
},
|
||||
(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)
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface PropertyCreateInput {
|
||||
createdBy: string;
|
||||
name: string;
|
||||
kind?: string | null;
|
||||
parentId?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
@@ -16,9 +17,28 @@ export interface PropertyCreateInput {
|
||||
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 }> {
|
||||
if (input.parentId) {
|
||||
await assertParentInCompany(input.companyId, input.parentId);
|
||||
}
|
||||
const values: NewProperty = {
|
||||
companyId: input.companyId,
|
||||
parentId: input.parentId ?? null,
|
||||
name: input.name.trim(),
|
||||
kind: input.kind ?? null,
|
||||
addressLine1: input.addressLine1 ?? null,
|
||||
@@ -62,11 +82,17 @@ export async function updateProperty(
|
||||
id: string,
|
||||
patch: Partial<PropertyCreateInput>
|
||||
): 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
|
||||
.update(properties)
|
||||
.set({
|
||||
...(patch.name !== undefined && { name: patch.name.trim() }),
|
||||
...(patch.kind !== undefined && { kind: patch.kind ?? null }),
|
||||
...(patch.parentId !== undefined && { parentId: patch.parentId ?? null }),
|
||||
...(patch.addressLine1 !== undefined && { addressLine1: patch.addressLine1 ?? null }),
|
||||
...(patch.addressLine2 !== undefined && { addressLine2: patch.addressLine2 ?? 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> {
|
||||
// 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
|
||||
.update(properties)
|
||||
.set({ deletedAt: sql`now()` })
|
||||
.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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user