feat(properties): tree-aware readers for expenses, maintenance, checklists

Phase 2. Each service grows a `*ForProperties` (plural) variant that
takes a resolved property id array; the existing single-property
functions now delegate to them with `[propertyId]`. The route layer
will compute the descendant set via getDescendantIds when the user
toggles "include sub-properties" — this commit only adds the readers
behind the scenes.

Also extends the checklist_scope enum with 'property' so checklists
can attach directly to a property and the rollup query has a
well-typed scope to filter on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:51:59 +07:00
parent 8117253841
commit 3b34458a99
7 changed files with 4743 additions and 9 deletions
@@ -0,0 +1 @@
ALTER TYPE "public"."checklist_scope" ADD VALUE 'property';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -127,6 +127,13 @@
"when": 1777268853484,
"tag": "0017_property_parent_check",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1777268985448,
"tag": "0018_checklist_scope_property",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -33,7 +33,8 @@ export const checklistScopeEnum = pgEnum('checklist_scope', [
'task',
'subtask',
'maintenance_event',
'ad_hoc'
'ad_hoc',
'property'
]);
export const docScopeEnum = pgEnum('doc_scope', [
'project',
+29 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import {
checklistInstances,
@@ -9,7 +9,12 @@ import {
type ChecklistTemplateItem
} from '$lib/server/db/schema/checklists';
export type ChecklistScope = 'task' | 'subtask' | 'maintenance_event' | 'ad_hoc';
export type ChecklistScope =
| 'task'
| 'subtask'
| 'maintenance_event'
| 'ad_hoc'
| 'property';
// --- templates ---------------------------------------------------------------
@@ -252,3 +257,25 @@ export async function listInstancesForScope(
)
.orderBy(desc(checklistInstances.createdAt));
}
/**
* Checklist instances scoped to any of the given property ids. Used by the
* property roll-up tab when "include sub-properties" is on.
*/
export async function listInstancesForProperties(
companyId: string,
propertyIds: string[]
) {
if (propertyIds.length === 0) return [];
return db
.select()
.from(checklistInstances)
.where(
and(
eq(checklistInstances.companyId, companyId),
eq(checklistInstances.scopeType, 'property'),
inArray(checklistInstances.scopeId, propertyIds)
)
)
.orderBy(desc(checklistInstances.createdAt));
}
+49 -5
View File
@@ -165,7 +165,21 @@ export async function listExpensesForProperty(
opts: { kinds?: ExpenseKind[]; limit?: number } = {}
): Promise<PropertyExpense[]> {
await assertProperty(companyId, propertyId);
const where = [eq(propertyExpenses.propertyId, propertyId)];
return listExpensesForProperties([propertyId], opts);
}
/**
* Tree-aware list. Caller resolves descendants via getDescendantIds (in
* services/properties) and passes the full id array. No per-property auth
* check — caller is responsible for ensuring every id belongs to the active
* company. Empty array returns [].
*/
export async function listExpensesForProperties(
propertyIds: string[],
opts: { kinds?: ExpenseKind[]; limit?: number } = {}
): Promise<PropertyExpense[]> {
if (propertyIds.length === 0) return [];
const where = [inArray(propertyExpenses.propertyId, propertyIds)];
if (opts.kinds && opts.kinds.length > 0) {
where.push(inArray(propertyExpenses.kind, opts.kinds));
}
@@ -199,7 +213,18 @@ export async function monthlySeriesForProperty(
months: ChartRange = 12
): Promise<MonthlySeriesPoint[]> {
await assertProperty(companyId, propertyId);
if (kinds.length === 0) return [];
return monthlySeriesForProperties([propertyId], kinds, months);
}
/**
* Tree-aware monthly series. Caller passes the resolved id array.
*/
export async function monthlySeriesForProperties(
propertyIds: string[],
kinds: ExpenseKind[],
months: ChartRange = 12
): Promise<MonthlySeriesPoint[]> {
if (propertyIds.length === 0 || kinds.length === 0) return [];
const now = new Date();
const thisMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
@@ -214,7 +239,7 @@ export async function monthlySeriesForProperty(
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.propertyId, propertyIds),
inArray(propertyExpenses.kind, kinds)
)
);
@@ -241,7 +266,7 @@ export async function monthlySeriesForProperty(
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.propertyId, propertyIds),
inArray(propertyExpenses.kind, kinds),
gte(propertyExpenses.incurredAt, start),
lt(propertyExpenses.incurredAt, new Date(Date.UTC(thisMonthStart.getUTCFullYear(), thisMonthStart.getUTCMonth() + 1, 1)))
@@ -279,6 +304,25 @@ export async function summaryForProperty(
currency: string | null;
}> {
await assertProperty(companyId, propertyId);
return summaryForProperties([propertyId], days);
}
/**
* Tree-aware summary. Caller passes resolved id array. When children carry
* different currencies, `currency` is null — the UI groups by currency in
* that case rather than auto-converting.
*/
export async function summaryForProperties(
propertyIds: string[],
days = 365
): Promise<{
byKind: Partial<Record<ExpenseKind, number>>;
grandTotal: number;
currency: string | null;
}> {
if (propertyIds.length === 0) {
return { byKind: {}, grandTotal: 0, currency: null };
}
const since = new Date(Date.now() - days * 86_400_000);
const rows = await db
.select({
@@ -289,7 +333,7 @@ export async function summaryForProperty(
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.propertyId, propertyIds),
gte(propertyExpenses.incurredAt, since)
)
)
+72 -1
View File
@@ -1,5 +1,5 @@
import { addDays, addHours, addMonths, addYears } from 'date-fns';
import { and, asc, desc, eq, isNotNull, lte, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, isNotNull, lte, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import {
@@ -349,6 +349,77 @@ export async function listDueAndOverdue(opts: OverdueOpts) {
.limit(opts.limit ?? 50);
}
// --- property-tree readers --------------------------------------------------
/**
* Maintenance schedules for assets currently located at any of the given
* properties. Caller passes a property id array (e.g. expanded via
* getDescendantIds).
*/
export async function listSchedulesForProperties(companyId: string, propertyIds: string[]) {
if (propertyIds.length === 0) return [];
return db
.select({
id: maintenanceSchedules.id,
name: maintenanceSchedules.name,
kind: maintenanceSchedules.kind,
active: maintenanceSchedules.active,
intervalValue: maintenanceSchedules.intervalValue,
intervalUnit: maintenanceSchedules.intervalUnit,
lastServicedAt: maintenanceSchedules.lastServicedAt,
nextDueAt: maintenanceSchedules.nextDueAt,
nextDueUsage: maintenanceSchedules.nextDueUsage,
assetId: maintenanceSchedules.assetId,
assetName: assets.name,
propertyId: assets.currentPropertyId
})
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.where(
and(
eq(assets.companyId, companyId),
inArray(assets.currentPropertyId, propertyIds)
)
)
.orderBy(desc(maintenanceSchedules.active), asc(maintenanceSchedules.nextDueAt));
}
/**
* Maintenance events for assets at any of the given properties. Recent first.
*/
export async function listEventsForProperties(
companyId: string,
propertyIds: string[],
limit = 200
) {
if (propertyIds.length === 0) return [];
return db
.select({
id: maintenanceEvents.id,
scheduleId: maintenanceEvents.scheduleId,
scheduleName: maintenanceSchedules.name,
performedAt: maintenanceEvents.performedAt,
performedBy: maintenanceEvents.performedBy,
notes: maintenanceEvents.notes,
usageReading: maintenanceEvents.usageReading,
checklistInstanceId: maintenanceEvents.checklistInstanceId,
assetId: maintenanceEvents.assetId,
assetName: assets.name,
propertyId: assets.currentPropertyId
})
.from(maintenanceEvents)
.innerJoin(assets, eq(assets.id, maintenanceEvents.assetId))
.leftJoin(maintenanceSchedules, eq(maintenanceSchedules.id, maintenanceEvents.scheduleId))
.where(
and(
eq(assets.companyId, companyId),
inArray(assets.currentPropertyId, propertyIds)
)
)
.orderBy(desc(maintenanceEvents.performedAt))
.limit(limit);
}
export async function countOverdueForCompany(companyId: string): Promise<number> {
const [row] = await db
.select({ n: sql<number>`count(*)::int` })