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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user