Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09e0fdc9ac | |||
| f5e4743120 | |||
| b4108c5a36 |
@@ -190,6 +190,66 @@ systemctl status buildfor_life_ops
|
|||||||
journalctl -u buildfor_life_ops -f
|
journalctl -u buildfor_life_ops -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Maintenance reminders timer
|
||||||
|
|
||||||
|
The app records `next_due_at` on every time-based maintenance schedule but does not poll itself. A daily systemd timer runs `pnpm run reminders:check`, which scans for schedules entering the 7-day warning window or already overdue and fans out via the existing in-app + email + Matrix notifier. Re-runs are idempotent — `maintenance_reminders_sent` deduplicates per `(schedule, kind, due_at)`.
|
||||||
|
|
||||||
|
`/etc/systemd/system/buildfor_life_ops-reminders.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=buildfor_life_ops maintenance reminder cron
|
||||||
|
After=postgresql.service network.target
|
||||||
|
Wants=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=ops
|
||||||
|
Group=ops
|
||||||
|
WorkingDirectory=/home/ops/buildfor_life_ops
|
||||||
|
EnvironmentFile=/home/ops/buildfor_life_ops/.env
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/home/ops/.local/share/fnm/aliases/default/bin/pnpm run reminders:check
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/buildfor_life_ops-reminders.timer`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Run buildfor_life_ops maintenance reminders daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 06:00:00
|
||||||
|
Persistent=true
|
||||||
|
Unit=buildfor_life_ops-reminders.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now buildfor_life_ops-reminders.timer
|
||||||
|
sudo systemctl list-timers buildfor_life_ops-reminders.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
**First-run protocol** to avoid a deluge of stale alerts on day one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inspect what would fire without notifying.
|
||||||
|
sudo -iu ops bash -lc 'cd ~/buildfor_life_ops && pnpm run reminders:check -- --dry-run'
|
||||||
|
|
||||||
|
# If the count is reasonable, run normally — the timer will pick up subsequent
|
||||||
|
# windows automatically. Or, if you want a clean slate, mark everything
|
||||||
|
# currently-due as already-notified (no fan-out), so day-one alerts only
|
||||||
|
# new breaches:
|
||||||
|
sudo -iu ops bash -lc 'cd ~/buildfor_life_ops && pnpm run reminders:check -- --backfill'
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs end up in `journalctl -u buildfor_life_ops-reminders.service`. Each run prints a single JSON line (`{ ok, scanned, fired, skippedDedup, noRecipients, ... }`) so `journalctl --output=cat | grep '"ok":true' | jq` gives a clean trend view.
|
||||||
|
|
||||||
## 11. Reverse proxy (nginx)
|
## 11. Reverse proxy (nginx)
|
||||||
|
|
||||||
`/etc/nginx/sites-available/buildfor_life_ops`:
|
`/etc/nginx/sites-available/buildfor_life_ops`:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TYPE "public"."maintenance_reminder_kind" AS ENUM('due_soon', 'overdue');--> statement-breakpoint
|
||||||
|
ALTER TYPE "public"."notification_kind" ADD VALUE 'maintenance_due_soon' BEFORE 'generic';--> statement-breakpoint
|
||||||
|
ALTER TYPE "public"."notification_kind" ADD VALUE 'maintenance_overdue' BEFORE 'generic';--> statement-breakpoint
|
||||||
|
CREATE TABLE "maintenance_reminders_sent" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"schedule_id" uuid NOT NULL,
|
||||||
|
"kind" "maintenance_reminder_kind" NOT NULL,
|
||||||
|
"due_at" timestamp with time zone NOT NULL,
|
||||||
|
"fired_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "maintenance_reminders_sent" ADD CONSTRAINT "maintenance_reminders_sent_schedule_id_maintenance_schedules_id_fk" FOREIGN KEY ("schedule_id") REFERENCES "public"."maintenance_schedules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "mrs_schedule_kind_due_uq" ON "maintenance_reminders_sent" USING btree ("schedule_id","kind","due_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "mrs_by_schedule" ON "maintenance_reminders_sent" USING btree ("schedule_id","kind");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
|||||||
"when": 1777268985448,
|
"when": 1777268985448,
|
||||||
"tag": "0018_checklist_scope_property",
|
"tag": "0018_checklist_scope_property",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777281036233,
|
||||||
|
"tag": "0019_maintenance_reminders",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "tsx scripts/seed/system-asset-types.ts",
|
"db:seed": "tsx scripts/seed/system-asset-types.ts",
|
||||||
"create-user": "tsx scripts/create-user.ts",
|
"create-user": "tsx scripts/create-user.ts",
|
||||||
|
"reminders:check": "tsx scripts/maintenance-reminders.ts",
|
||||||
"prepare": "husky || true"
|
"prepare": "husky || true"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { pool } from '../src/lib/server/db/client';
|
||||||
|
import {
|
||||||
|
runRemindersOnce,
|
||||||
|
type RunResult
|
||||||
|
} from '../src/lib/server/services/maintenance-reminders';
|
||||||
|
|
||||||
|
function stripSurroundingQuotes(s: string | undefined): string | undefined {
|
||||||
|
if (!s || s.length < 2) return s;
|
||||||
|
const first = s[0];
|
||||||
|
const last = s[s.length - 1];
|
||||||
|
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readArg(flag: string, fallback?: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(flag);
|
||||||
|
return stripSurroundingQuotes(i >= 0 ? process.argv[i + 1] : fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(flag: string): boolean {
|
||||||
|
return process.argv.includes(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const soonRaw = readArg('--soon-days', '7') ?? '7';
|
||||||
|
const soonDays = Number.parseInt(soonRaw, 10);
|
||||||
|
if (!Number.isFinite(soonDays) || soonDays < 0 || soonDays > 365) {
|
||||||
|
console.error('--soon-days must be an integer in [0, 365]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const companyId = readArg('--company');
|
||||||
|
const dryRun = hasFlag('--dry-run');
|
||||||
|
const backfill = hasFlag('--backfill');
|
||||||
|
|
||||||
|
if (dryRun && backfill) {
|
||||||
|
console.error('--dry-run and --backfill are mutually exclusive');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = new Date();
|
||||||
|
let result: RunResult;
|
||||||
|
try {
|
||||||
|
result = await runRemindersOnce({ companyId, soonDays, dryRun, backfill });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(JSON.stringify({ ok: false, error: (e as Error).message }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
ok: true,
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
durationMs: Date.now() - startedAt.getTime(),
|
||||||
|
soonDays,
|
||||||
|
companyId: companyId ?? null,
|
||||||
|
...result
|
||||||
|
};
|
||||||
|
// Single-line JSON so journald/grep handle it cleanly.
|
||||||
|
console.log(JSON.stringify(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(JSON.stringify({ ok: false, error: (e as Error).message }));
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await pool.end().catch(() => {});
|
||||||
|
});
|
||||||
@@ -76,8 +76,15 @@ export const notificationKindEnum = pgEnum('notification_kind', [
|
|||||||
'asset_moved',
|
'asset_moved',
|
||||||
'decision_created',
|
'decision_created',
|
||||||
'maintenance_event_recorded',
|
'maintenance_event_recorded',
|
||||||
|
'maintenance_due_soon',
|
||||||
|
'maintenance_overdue',
|
||||||
'generic'
|
'generic'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const maintenanceReminderKindEnum = pgEnum('maintenance_reminder_kind', [
|
||||||
|
'due_soon',
|
||||||
|
'overdue'
|
||||||
|
]);
|
||||||
export const expenseKindEnum = pgEnum('expense_kind', [
|
export const expenseKindEnum = pgEnum('expense_kind', [
|
||||||
'water',
|
'water',
|
||||||
'electricity',
|
'electricity',
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { pgTable, varchar, text, integer, numeric, timestamp, boolean, index } from 'drizzle-orm/pg-core';
|
import { pgTable, varchar, text, integer, numeric, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { users } from './tenancy';
|
import { users } from './tenancy';
|
||||||
import { assets } from './assets';
|
import { assets } from './assets';
|
||||||
import { checklistTemplates, checklistInstances } from './checklists';
|
import { checklistTemplates, checklistInstances } from './checklists';
|
||||||
import { scheduleKindEnum, intervalUnitEnum, pk, fk, createdAt, updatedAt } from './_shared';
|
import {
|
||||||
|
scheduleKindEnum,
|
||||||
|
intervalUnitEnum,
|
||||||
|
maintenanceReminderKindEnum,
|
||||||
|
pk,
|
||||||
|
fk,
|
||||||
|
createdAt,
|
||||||
|
updatedAt
|
||||||
|
} from './_shared';
|
||||||
|
|
||||||
export const maintenanceSchedules = pgTable(
|
export const maintenanceSchedules = pgTable(
|
||||||
'maintenance_schedules',
|
'maintenance_schedules',
|
||||||
@@ -80,7 +88,32 @@ export const maintenanceEvents = pgTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dedup log for the reminder cron. We insert (schedule_id, kind, due_at)
|
||||||
|
// before firing notify(); the unique index makes the insert atomic, so
|
||||||
|
// concurrent or repeated runs cannot double-fire for the same window.
|
||||||
|
// After service is recorded, schedules.next_due_at advances → new tuple →
|
||||||
|
// reminder fires fresh. Rows are kept indefinitely for audit; a separate
|
||||||
|
// cleanup task can prune > 90 days old.
|
||||||
|
export const maintenanceRemindersSent = pgTable(
|
||||||
|
'maintenance_reminders_sent',
|
||||||
|
{
|
||||||
|
id: pk(),
|
||||||
|
scheduleId: fk('schedule_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => maintenanceSchedules.id, { onDelete: 'cascade' }),
|
||||||
|
kind: maintenanceReminderKindEnum('kind').notNull(),
|
||||||
|
dueAt: timestamp('due_at', { withTimezone: true }).notNull(),
|
||||||
|
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
// The dedup key. INSERT … ON CONFLICT DO NOTHING relies on this.
|
||||||
|
dedupUq: uniqueIndex('mrs_schedule_kind_due_uq').on(t.scheduleId, t.kind, t.dueAt),
|
||||||
|
bySchedule: index('mrs_by_schedule').on(t.scheduleId, t.kind)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export type MaintenanceSchedule = typeof maintenanceSchedules.$inferSelect;
|
export type MaintenanceSchedule = typeof maintenanceSchedules.$inferSelect;
|
||||||
export type NewMaintenanceSchedule = typeof maintenanceSchedules.$inferInsert;
|
export type NewMaintenanceSchedule = typeof maintenanceSchedules.$inferInsert;
|
||||||
export type UsageReading = typeof usageReadings.$inferSelect;
|
export type UsageReading = typeof usageReadings.$inferSelect;
|
||||||
export type MaintenanceEvent = typeof maintenanceEvents.$inferSelect;
|
export type MaintenanceEvent = typeof maintenanceEvents.$inferSelect;
|
||||||
|
export type MaintenanceReminderSent = typeof maintenanceRemindersSent.$inferSelect;
|
||||||
|
|||||||
@@ -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'
|
| 'asset_moved'
|
||||||
| 'decision_created'
|
| 'decision_created'
|
||||||
| 'maintenance_event_recorded'
|
| 'maintenance_event_recorded'
|
||||||
|
| 'maintenance_due_soon'
|
||||||
|
| 'maintenance_overdue'
|
||||||
| 'generic';
|
| 'generic';
|
||||||
|
|
||||||
export interface NotifyInput {
|
export interface NotifyInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user