diff --git a/src/lib/server/services/maintenance-reminders.ts b/src/lib/server/services/maintenance-reminders.ts new file mode 100644 index 0000000..dd0986e --- /dev/null +++ b/src/lib/server/services/maintenance-reminders.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + + 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; +} diff --git a/src/lib/server/services/notifications.ts b/src/lib/server/services/notifications.ts index cbe8beb..ddcf889 100644 --- a/src/lib/server/services/notifications.ts +++ b/src/lib/server/services/notifications.ts @@ -12,6 +12,8 @@ export type NotificationKind = | 'asset_moved' | 'decision_created' | 'maintenance_event_recorded' + | 'maintenance_due_soon' + | 'maintenance_overdue' | 'generic'; export interface NotifyInput {