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:
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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` })
|
||||
|
||||
Reference in New Issue
Block a user