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:
@@ -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,
|
||||
"tag": "0018_checklist_scope_property",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1777281036233,
|
||||
"tag": "0019_maintenance_reminders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -76,8 +76,15 @@ export const notificationKindEnum = pgEnum('notification_kind', [
|
||||
'asset_moved',
|
||||
'decision_created',
|
||||
'maintenance_event_recorded',
|
||||
'maintenance_due_soon',
|
||||
'maintenance_overdue',
|
||||
'generic'
|
||||
]);
|
||||
|
||||
export const maintenanceReminderKindEnum = pgEnum('maintenance_reminder_kind', [
|
||||
'due_soon',
|
||||
'overdue'
|
||||
]);
|
||||
export const expenseKindEnum = pgEnum('expense_kind', [
|
||||
'water',
|
||||
'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 { assets } from './assets';
|
||||
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(
|
||||
'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 NewMaintenanceSchedule = typeof maintenanceSchedules.$inferInsert;
|
||||
export type UsageReading = typeof usageReadings.$inferSelect;
|
||||
export type MaintenanceEvent = typeof maintenanceEvents.$inferSelect;
|
||||
export type MaintenanceReminderSent = typeof maintenanceRemindersSent.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user