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:
@@ -0,0 +1,46 @@
|
||||
-- Phase 3: move Schedule from per-user to per-License-Key, add weekly support.
|
||||
|
||||
-- AlterTable: Schedule gains licenseKeyId + type, userId becomes optional.
|
||||
ALTER TABLE "Schedule" ADD COLUMN "licenseKeyId" TEXT,
|
||||
ADD COLUMN "type" TEXT;
|
||||
|
||||
-- AlterTable: Block gains day; new Blocks default dynamicAudio true.
|
||||
ALTER TABLE "Block" ADD COLUMN "day" TEXT;
|
||||
ALTER TABLE "Block" ALTER COLUMN "dynamicAudio" SET DEFAULT true;
|
||||
|
||||
-- DropIndex: userId is no longer the schedule identity.
|
||||
DROP INDEX "Schedule_userId_key";
|
||||
|
||||
-- AlterTable: legacy owner becomes optional and non-unique.
|
||||
ALTER TABLE "Schedule" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Schedule_licenseKeyId_key" ON "Schedule"("licenseKeyId");
|
||||
CREATE INDEX "Schedule_userId_idx" ON "Schedule"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_licenseKeyId_fkey" FOREIGN KEY ("licenseKeyId") REFERENCES "LicenseKey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: map each existing per-user Schedule to the account's oldest
|
||||
-- License Key. Existing content was daily-only.
|
||||
UPDATE "Schedule" s
|
||||
SET "licenseKeyId" = lk."id",
|
||||
"type" = COALESCE(s."type", 'daily')
|
||||
FROM (
|
||||
SELECT DISTINCT ON ("userId") "id", "userId"
|
||||
FROM "LicenseKey"
|
||||
WHERE "userId" IS NOT NULL
|
||||
ORDER BY "userId", "createdAt" ASC, "id" ASC
|
||||
) lk
|
||||
WHERE s."userId" = lk."userId"
|
||||
AND s."licenseKeyId" IS NULL;
|
||||
|
||||
-- Report (do not silently drop) schedules that cannot be mapped to a key.
|
||||
DO $$
|
||||
DECLARE unmapped integer;
|
||||
BEGIN
|
||||
SELECT count(*) INTO unmapped FROM "Schedule" WHERE "licenseKeyId" IS NULL;
|
||||
IF unmapped > 0 THEN
|
||||
RAISE NOTICE 'Phase 3 migration: % Schedule row(s) could not be mapped to a License Key (no owning user/license); retained with licenseKeyId NULL.', unmapped;
|
||||
END IF;
|
||||
END $$;
|
||||
+28
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user