feat(maintenance): schema for reminders dedup log

Phase 1 of the maintenance-reminders feature.

- notification_kind: + maintenance_due_soon, maintenance_overdue.
  These are distinct from maintenance_event_recorded (service
  performed) so the bell list can group/filter reminder vs service
  cleanly.
- New maintenance_reminder_kind enum: due_soon | overdue.
- New maintenance_reminders_sent table with UNIQUE(schedule_id,
  kind, due_at). The cron uses INSERT … ON CONFLICT DO NOTHING on
  this composite to make the fire-once-per-window guarantee atomic
  even under concurrent runs. Once the schedule's next_due_at
  advances after a service event, the tuple is fresh and a new
  reminder fires.

No service code yet — Phase 2 wires the readers and orchestrator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 16:11:34 +07:00
parent 435bcb981f
commit b4108c5a36
5 changed files with 4765 additions and 2 deletions
+14
View File
@@ -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
+7
View File
@@ -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
} }
] ]
} }
+7
View File
@@ -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',
+35 -2
View File
@@ -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;