feat(maintenance): reminder service — findDueSchedules + runRemindersOnce

Phase 2 of maintenance reminders. Pure server-side, no UI changes.

findDueSchedules joins active time-based schedules to assets and
properties, computes the kind (overdue if past, due_soon if within
the warning window), and returns a flat shape ready for the
notification body. Usage-based schedules are intentionally excluded
— they need a different trigger (usage-reading crossover).

runRemindersOnce orchestrates: for each due schedule, look up the
company's admin+manager user_ids, atomically claim the
(schedule, kind, due_at) tuple via INSERT … ON CONFLICT DO NOTHING,
and only call notify() if we won the insert. The unique index on
maintenance_reminders_sent makes the dedup atomic across concurrent
runs and across re-invocations of the cron.

Two opt-in flags on the orchestrator: dryRun (count what would
fire without touching the DB or fanning out) and backfill (record
everything currently due as already-sent so day-one of the cron
is silent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 16:13:46 +07:00
parent b4108c5a36
commit f5e4743120
2 changed files with 228 additions and 0 deletions
@@ -0,0 +1,226 @@
import { and, eq, inArray, isNotNull, lte } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import {
maintenanceRemindersSent,
maintenanceSchedules
} from '$lib/server/db/schema/maintenance';
import { companyUsers } from '$lib/server/db/schema/tenancy';
import { notify } from './notifications';
export type ReminderKind = 'due_soon' | 'overdue';
export interface DueSchedule {
scheduleId: string;
scheduleName: string;
nextDueAt: Date;
kind: ReminderKind;
assetId: string;
assetName: string;
companyId: string;
propertyId: string | null;
propertyName: string | null;
}
/**
* Time-based active schedules whose next_due_at is overdue or within the
* `soonDays` window. Joined to asset → property so the notification body can
* cite the location. Usage-based schedules don't have next_due_at and are
* intentionally excluded — they need a different trigger (usage-reading
* crossover) which isn't part of this iteration.
*/
export async function findDueSchedules(opts: {
companyId?: string;
soonDays: number;
now?: Date;
}): Promise<DueSchedule[]> {
const now = opts.now ?? new Date();
const horizon = new Date(now.getTime() + opts.soonDays * 86_400_000);
const where = [
eq(maintenanceSchedules.active, true),
eq(maintenanceSchedules.kind, 'time'),
isNotNull(maintenanceSchedules.nextDueAt),
lte(maintenanceSchedules.nextDueAt, horizon)
];
if (opts.companyId) where.push(eq(assets.companyId, opts.companyId));
const rows = await db
.select({
scheduleId: maintenanceSchedules.id,
scheduleName: maintenanceSchedules.name,
nextDueAt: maintenanceSchedules.nextDueAt,
assetId: maintenanceSchedules.assetId,
assetName: assets.name,
companyId: assets.companyId,
propertyId: assets.currentPropertyId,
propertyName: properties.name
})
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.leftJoin(properties, eq(properties.id, assets.currentPropertyId))
.where(and(...where));
return rows
.filter((r): r is typeof r & { nextDueAt: Date } => r.nextDueAt !== null)
.map((r) => ({
scheduleId: r.scheduleId,
scheduleName: r.scheduleName,
nextDueAt: r.nextDueAt,
kind: r.nextDueAt < now ? ('overdue' as const) : ('due_soon' as const),
assetId: r.assetId,
assetName: r.assetName,
companyId: r.companyId,
propertyId: r.propertyId,
propertyName: r.propertyName
}));
}
/** admin + manager user_ids for a company. */
async function recipientsFor(companyId: string): Promise<string[]> {
const rows = await db
.select({ userId: companyUsers.userId })
.from(companyUsers)
.where(
and(
eq(companyUsers.companyId, companyId),
inArray(companyUsers.role, ['admin', 'manager'])
)
);
return Array.from(new Set(rows.map((r) => r.userId)));
}
/**
* Try to record that a reminder is being fired. Returns true if this is the
* first time for (schedule, kind, due_at), false if it was already logged.
* The unique index makes this atomic across concurrent runs.
*/
async function tryRecordSent(
scheduleId: string,
kind: ReminderKind,
dueAt: Date
): Promise<boolean> {
const result = await db
.insert(maintenanceRemindersSent)
.values({ scheduleId, kind, dueAt })
.onConflictDoNothing({
target: [
maintenanceRemindersSent.scheduleId,
maintenanceRemindersSent.kind,
maintenanceRemindersSent.dueAt
]
})
.returning({ id: maintenanceRemindersSent.id });
return result.length > 0;
}
function buildBody(s: DueSchedule, now: Date): string {
const due = s.nextDueAt;
const days = Math.round((due.getTime() - now.getTime()) / 86_400_000);
const where = s.propertyName ? ` at ${s.propertyName}` : '';
if (s.kind === 'overdue') {
const overdueDays = Math.max(0, -days);
return `${s.scheduleName} on ${s.assetName}${where} is overdue by ${overdueDays} day${overdueDays === 1 ? '' : 's'} (was due ${due.toISOString().slice(0, 10)}).`;
}
return `${s.scheduleName} on ${s.assetName}${where} is due in ${days} day${days === 1 ? '' : 's'} (${due.toISOString().slice(0, 10)}).`;
}
export interface RunOpts {
companyId?: string;
soonDays: number;
now?: Date;
dryRun?: boolean;
/**
* On first deploy, mark every currently-due/overdue schedule as already
* notified so day-one isn't a deluge of stale alerts. Returns count in
* `backfilled`. Existing dedup rows are not touched (still ON CONFLICT
* DO NOTHING).
*/
backfill?: boolean;
}
export interface RunResult {
scanned: number;
fired: number;
skippedDedup: number;
noRecipients: number;
backfilled: number;
dryRun: boolean;
}
export async function runRemindersOnce(opts: RunOpts): Promise<RunResult> {
const now = opts.now ?? new Date();
const due = await findDueSchedules({
companyId: opts.companyId,
soonDays: opts.soonDays,
now
});
const result: RunResult = {
scanned: due.length,
fired: 0,
skippedDedup: 0,
noRecipients: 0,
backfilled: 0,
dryRun: !!opts.dryRun
};
// Backfill: log everything as already-sent without notifying. Useful on
// first deploy. Doesn't depend on dryRun because backfill is the entire
// effect when set.
if (opts.backfill) {
for (const s of due) {
const inserted = await tryRecordSent(s.scheduleId, s.kind, s.nextDueAt);
if (inserted) result.backfilled += 1;
else result.skippedDedup += 1;
}
return result;
}
// Cache recipients per company within this run.
const recipientsCache = new Map<string, string[]>();
for (const s of due) {
let userIds = recipientsCache.get(s.companyId);
if (!userIds) {
userIds = await recipientsFor(s.companyId);
recipientsCache.set(s.companyId, userIds);
}
if (userIds.length === 0) {
result.noRecipients += 1;
continue;
}
if (opts.dryRun) {
result.fired += 1;
continue;
}
// Atomically claim this (schedule, kind, due_at). Only proceed to notify
// if we won the insert.
const claimed = await tryRecordSent(s.scheduleId, s.kind, s.nextDueAt);
if (!claimed) {
result.skippedDedup += 1;
continue;
}
const title =
s.kind === 'overdue'
? `Maintenance overdue: ${s.scheduleName}`
: `Maintenance due soon: ${s.scheduleName}`;
const notificationKind =
s.kind === 'overdue' ? 'maintenance_overdue' : 'maintenance_due_soon';
await notify({
companyId: s.companyId,
userIds,
kind: notificationKind,
title,
body: buildBody(s, now),
link: `/assets/${s.assetId}/maintenance`
});
result.fired += 1;
}
return result;
}
+2
View File
@@ -12,6 +12,8 @@ export type NotificationKind =
| 'asset_moved'
| 'decision_created'
| 'maintenance_event_recorded'
| 'maintenance_due_soon'
| 'maintenance_overdue'
| 'generic';
export interface NotifyInput {