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:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."checklist_scope" ADD VALUE 'property';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
|||||||
"when": 1777268853484,
|
"when": 1777268853484,
|
||||||
"tag": "0017_property_parent_check",
|
"tag": "0017_property_parent_check",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777268985448,
|
||||||
|
"tag": "0018_checklist_scope_property",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,8 @@ export const checklistScopeEnum = pgEnum('checklist_scope', [
|
|||||||
'task',
|
'task',
|
||||||
'subtask',
|
'subtask',
|
||||||
'maintenance_event',
|
'maintenance_event',
|
||||||
'ad_hoc'
|
'ad_hoc',
|
||||||
|
'property'
|
||||||
]);
|
]);
|
||||||
export const docScopeEnum = pgEnum('doc_scope', [
|
export const docScopeEnum = pgEnum('doc_scope', [
|
||||||
'project',
|
'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 { db } from '$lib/server/db/client';
|
||||||
import {
|
import {
|
||||||
checklistInstances,
|
checklistInstances,
|
||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
type ChecklistTemplateItem
|
type ChecklistTemplateItem
|
||||||
} from '$lib/server/db/schema/checklists';
|
} 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 ---------------------------------------------------------------
|
// --- templates ---------------------------------------------------------------
|
||||||
|
|
||||||
@@ -252,3 +257,25 @@ export async function listInstancesForScope(
|
|||||||
)
|
)
|
||||||
.orderBy(desc(checklistInstances.createdAt));
|
.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 } = {}
|
opts: { kinds?: ExpenseKind[]; limit?: number } = {}
|
||||||
): Promise<PropertyExpense[]> {
|
): Promise<PropertyExpense[]> {
|
||||||
await assertProperty(companyId, propertyId);
|
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) {
|
if (opts.kinds && opts.kinds.length > 0) {
|
||||||
where.push(inArray(propertyExpenses.kind, opts.kinds));
|
where.push(inArray(propertyExpenses.kind, opts.kinds));
|
||||||
}
|
}
|
||||||
@@ -199,7 +213,18 @@ export async function monthlySeriesForProperty(
|
|||||||
months: ChartRange = 12
|
months: ChartRange = 12
|
||||||
): Promise<MonthlySeriesPoint[]> {
|
): Promise<MonthlySeriesPoint[]> {
|
||||||
await assertProperty(companyId, propertyId);
|
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 now = new Date();
|
||||||
const thisMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
const thisMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
@@ -214,7 +239,7 @@ export async function monthlySeriesForProperty(
|
|||||||
.from(propertyExpenses)
|
.from(propertyExpenses)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(propertyExpenses.propertyId, propertyId),
|
inArray(propertyExpenses.propertyId, propertyIds),
|
||||||
inArray(propertyExpenses.kind, kinds)
|
inArray(propertyExpenses.kind, kinds)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -241,7 +266,7 @@ export async function monthlySeriesForProperty(
|
|||||||
.from(propertyExpenses)
|
.from(propertyExpenses)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(propertyExpenses.propertyId, propertyId),
|
inArray(propertyExpenses.propertyId, propertyIds),
|
||||||
inArray(propertyExpenses.kind, kinds),
|
inArray(propertyExpenses.kind, kinds),
|
||||||
gte(propertyExpenses.incurredAt, start),
|
gte(propertyExpenses.incurredAt, start),
|
||||||
lt(propertyExpenses.incurredAt, new Date(Date.UTC(thisMonthStart.getUTCFullYear(), thisMonthStart.getUTCMonth() + 1, 1)))
|
lt(propertyExpenses.incurredAt, new Date(Date.UTC(thisMonthStart.getUTCFullYear(), thisMonthStart.getUTCMonth() + 1, 1)))
|
||||||
@@ -279,6 +304,25 @@ export async function summaryForProperty(
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
}> {
|
}> {
|
||||||
await assertProperty(companyId, propertyId);
|
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 since = new Date(Date.now() - days * 86_400_000);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -289,7 +333,7 @@ export async function summaryForProperty(
|
|||||||
.from(propertyExpenses)
|
.from(propertyExpenses)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(propertyExpenses.propertyId, propertyId),
|
inArray(propertyExpenses.propertyId, propertyIds),
|
||||||
gte(propertyExpenses.incurredAt, since)
|
gte(propertyExpenses.incurredAt, since)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { addDays, addHours, addMonths, addYears } from 'date-fns';
|
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 { db } from '$lib/server/db/client';
|
||||||
import { assets } from '$lib/server/db/schema/assets';
|
import { assets } from '$lib/server/db/schema/assets';
|
||||||
import {
|
import {
|
||||||
@@ -349,6 +349,77 @@ export async function listDueAndOverdue(opts: OverdueOpts) {
|
|||||||
.limit(opts.limit ?? 50);
|
.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> {
|
export async function countOverdueForCompany(companyId: string): Promise<number> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ n: sql<number>`count(*)::int` })
|
.select({ n: sql<number>`count(*)::int` })
|
||||||
|
|||||||
Reference in New Issue
Block a user