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:
@@ -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;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export type NotificationKind =
|
||||
| 'asset_moved'
|
||||
| 'decision_created'
|
||||
| 'maintenance_event_recorded'
|
||||
| 'maintenance_due_soon'
|
||||
| 'maintenance_overdue'
|
||||
| 'generic';
|
||||
|
||||
export interface NotifyInput {
|
||||
|
||||
Reference in New Issue
Block a user