Portal: move Schedule to per-License-Key with daily/weekly and Block.day

Adds Schedule.licenseKeyId (unique, nullable) and Schedule.type, makes the
legacy userId optional/non-unique, adds Block.day and flips Block
dynamicAudio default to true. The migration backfills each existing
per-user schedule onto that account's oldest License Key as a daily
schedule, preserves unmappable schedules (licenseKeyId NULL) with a
NOTICE, and is reversible. Tested up/down against a scratch schema.
This commit is contained in:
NeilClack
2026-09-29 10:11:21 -05:00
parent 25b5eeb144
commit 138bf03ea9
9 changed files with 630 additions and 130 deletions
+28 -16
View File
@@ -28,7 +28,7 @@ model User {
profiles Profile[]
licenseKeys LicenseKey[]
userProclamations UserProclamations[]
schedule Schedule?
schedules Schedule[]
@@unique([email])
@@map("user")
@@ -205,6 +205,7 @@ model LicenseKey {
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String?
schedule Schedule?
// Human-readable label set only by the Kiosk (e.g. "Living Room"). Trimmed,
// 1-40 chars. Never a reference or foreign key; the License Key is the only
// identifier. `systemNameLower` is a case-folded copy used to enforce
@@ -263,31 +264,42 @@ model AuditLog {
@@map("AuditLog")
}
// A user's daily listening schedule. Exactly one per user (enforced by the
// unique userId); contains 0-4 time Blocks. Times are wall-clock minutes from
// midnight (0-1440), interpreted by the kiosk in the user's configured
// timezone. The portal never stores a timezone.
// A listening schedule assigned to one License Key (at most one per key).
// `type` is "daily" | "weekly" | null (null = no schedule). Times are
// wall-clock minutes from midnight (0-1440), interpreted by the kiosk in its
// own configured timezone. The portal never stores a timezone.
//
// `userId` is a legacy owner retained while per-user schedules are migrated to
// per-license-key; new schedules set `licenseKeyId` and leave `userId` null.
model Schedule {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
blocks Block[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
licenseKeyId String? @unique
licenseKey LicenseKey? @relation(fields: [licenseKeyId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
type String?
blocks Block[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// A single non-overlapping time range within a Schedule, plus the ordered set
// of selected Proclamations to play during it. Overnight ranges are disallowed
// in v1, so startMinutes is always less than endMinutes.
// A single time range within a Schedule, plus the ordered set of selected
// Proclamations to play during it. `day` is null for daily schedules and
// "mon".."sun" for weekly schedules. Overnight (midnight-crossing) ranges are
// valid: an end clock time before the start belongs to the start day and spills
// into the next day.
model Block {
id String @id @default(uuid())
scheduleId String
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
day String?
startMinutes Int
endMinutes Int
// When true, the kiosk enables dynamic audio for the duration of this block,
// overriding any audio selections made on the device.
dynamicAudio Boolean @default(false)
// overriding any audio selections made on the device. Portal default: true.
dynamicAudio Boolean @default(true)
proclamations BlockProclamations[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt