From 8e069f9b378a95fea92fd829a0059a2e4f16500a Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 21 Feb 2026 09:18:47 -0800 Subject: [PATCH 1/7] fix: coordinated schema migration for audit findings 18-21, 37-39 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines seven schema fixes into a single migration (0026) to avoid ordering conflicts between individual PRs: - F18: shift_covers.schedule_id serial → integer (drop auto-increment on FK) - F19: flowsheet.play_order serial → integer (manually managed, not auto-inc) - F20: artist_library_crossreference add NOT NULL on both FK columns - F21: show_djs add unique constraint on (show_id, dj_id) - F37: anonymous_devices remove redundant .unique() (keep explicit uniqueIndex) - F38: add ON DELETE cascade/set-null rules to 15 FK relationships - F39: add withTimezone to all 14 wxyc_schema timestamp columns Timestamp conversions use AT TIME ZONE 'America/New_York' to preserve the intended wall-clock times from the station's local timezone. Co-authored-by: Cursor --- .../migrations/0026_schema_audit_fixes.sql | 162 ++++++++++++++++++ .../src/migrations/meta/_journal.json | 7 + shared/database/src/schema.ts | 90 +++++----- 3 files changed, 216 insertions(+), 43 deletions(-) create mode 100644 shared/database/src/migrations/0026_schema_audit_fixes.sql diff --git a/shared/database/src/migrations/0026_schema_audit_fixes.sql b/shared/database/src/migrations/0026_schema_audit_fixes.sql new file mode 100644 index 0000000..a3ffd9d --- /dev/null +++ b/shared/database/src/migrations/0026_schema_audit_fixes.sql @@ -0,0 +1,162 @@ +-- Schema audit fixes (Findings 18, 19, 20, 21, 37, 38, 39) +-- This migration must be applied AFTER verifying no NULL rows exist in +-- artist_library_crossreference, and after confirming the shift_covers +-- and flowsheet sequences are not relied upon for auto-generation. + +-- F18: shift_covers.schedule_id serial → integer +-- Drop the auto-increment sequence. The column type is already integer in PG; +-- serial just adds a DEFAULT nextval() and owns the sequence. +ALTER TABLE "wxyc_schema"."shift_covers" ALTER COLUMN "schedule_id" DROP DEFAULT; +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'shift_covers_schedule_id_seq') THEN + DROP SEQUENCE "wxyc_schema"."shift_covers_schedule_id_seq"; + END IF; +END $$; +--> statement-breakpoint + +-- F19: flowsheet.play_order serial → integer +ALTER TABLE "wxyc_schema"."flowsheet" ALTER COLUMN "play_order" DROP DEFAULT; +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'flowsheet_play_order_seq') THEN + DROP SEQUENCE "wxyc_schema"."flowsheet_play_order_seq"; + END IF; +END $$; +--> statement-breakpoint + +-- F20: artist_library_crossreference – add NOT NULL to FK columns +-- IMPORTANT: Run this check first to confirm no NULLs exist: +-- SELECT count(*) FROM wxyc_schema.artist_library_crossreference +-- WHERE artist_id IS NULL OR library_id IS NULL; +ALTER TABLE "wxyc_schema"."artist_library_crossreference" ALTER COLUMN "artist_id" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."artist_library_crossreference" ALTER COLUMN "library_id" SET NOT NULL; +--> statement-breakpoint + +-- F21: show_djs – add unique constraint on (show_id, dj_id) +-- First remove any duplicate rows (keep the first inserted) +DELETE FROM "wxyc_schema"."show_djs" a USING "wxyc_schema"."show_djs" b + WHERE a.ctid < b.ctid + AND a.show_id = b.show_id + AND a.dj_id = b.dj_id; +--> statement-breakpoint +CREATE UNIQUE INDEX "show_djs_show_id_dj_id_unique" ON "wxyc_schema"."show_djs" USING btree ("show_id", "dj_id"); +--> statement-breakpoint + +-- F37: anonymous_devices – remove redundant unique constraint +-- The explicit uniqueIndex('anonymous_devices_device_id_key') remains. +-- Drop the inline .unique() constraint (named by PG convention). +ALTER TABLE "anonymous_devices" DROP CONSTRAINT IF EXISTS "anonymous_devices_device_id_unique"; +--> statement-breakpoint + +-- F38: FK cascade rules +-- schedule FKs +ALTER TABLE "wxyc_schema"."schedule" DROP CONSTRAINT IF EXISTS "schedule_specialty_id_specialty_shows_id_fk"; +ALTER TABLE "wxyc_schema"."schedule" ADD CONSTRAINT "schedule_specialty_id_specialty_shows_id_fk" + FOREIGN KEY ("specialty_id") REFERENCES "wxyc_schema"."specialty_shows"("id") ON DELETE SET NULL; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."schedule" DROP CONSTRAINT IF EXISTS "schedule_assigned_dj_id_auth_user_id_fk"; +ALTER TABLE "wxyc_schema"."schedule" ADD CONSTRAINT "schedule_assigned_dj_id_auth_user_id_fk" + FOREIGN KEY ("assigned_dj_id") REFERENCES "public"."auth_user"("id") ON DELETE SET NULL; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."schedule" DROP CONSTRAINT IF EXISTS "schedule_assigned_dj_id2_auth_user_id_fk"; +ALTER TABLE "wxyc_schema"."schedule" ADD CONSTRAINT "schedule_assigned_dj_id2_auth_user_id_fk" + FOREIGN KEY ("assigned_dj_id2") REFERENCES "public"."auth_user"("id") ON DELETE SET NULL; +--> statement-breakpoint + +-- shift_covers FK +ALTER TABLE "wxyc_schema"."shift_covers" DROP CONSTRAINT IF EXISTS "shift_covers_cover_dj_id_auth_user_id_fk"; +ALTER TABLE "wxyc_schema"."shift_covers" ADD CONSTRAINT "shift_covers_cover_dj_id_auth_user_id_fk" + FOREIGN KEY ("cover_dj_id") REFERENCES "public"."auth_user"("id") ON DELETE SET NULL; +--> statement-breakpoint + +-- rotation FK +ALTER TABLE "wxyc_schema"."rotation" DROP CONSTRAINT IF EXISTS "rotation_album_id_library_id_fk"; +ALTER TABLE "wxyc_schema"."rotation" ADD CONSTRAINT "rotation_album_id_library_id_fk" + FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE CASCADE; +--> statement-breakpoint + +-- flowsheet FKs +ALTER TABLE "wxyc_schema"."flowsheet" DROP CONSTRAINT IF EXISTS "flowsheet_show_id_shows_id_fk"; +ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_show_id_shows_id_fk" + FOREIGN KEY ("show_id") REFERENCES "wxyc_schema"."shows"("id") ON DELETE SET NULL; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."flowsheet" DROP CONSTRAINT IF EXISTS "flowsheet_album_id_library_id_fk"; +ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_album_id_library_id_fk" + FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE SET NULL; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."flowsheet" DROP CONSTRAINT IF EXISTS "flowsheet_rotation_id_rotation_id_fk"; +ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_rotation_id_rotation_id_fk" + FOREIGN KEY ("rotation_id") REFERENCES "wxyc_schema"."rotation"("id") ON DELETE SET NULL; +--> statement-breakpoint + +-- reviews FK +ALTER TABLE "wxyc_schema"."reviews" DROP CONSTRAINT IF EXISTS "reviews_album_id_library_id_fk"; +ALTER TABLE "wxyc_schema"."reviews" ADD CONSTRAINT "reviews_album_id_library_id_fk" + FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE CASCADE; +--> statement-breakpoint + +-- genre_artist_crossreference FKs +ALTER TABLE "wxyc_schema"."genre_artist_crossreference" DROP CONSTRAINT IF EXISTS "genre_artist_crossreference_artist_id_artists_id_fk"; +ALTER TABLE "wxyc_schema"."genre_artist_crossreference" ADD CONSTRAINT "genre_artist_crossreference_artist_id_artists_id_fk" + FOREIGN KEY ("artist_id") REFERENCES "wxyc_schema"."artists"("id") ON DELETE CASCADE; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."genre_artist_crossreference" DROP CONSTRAINT IF EXISTS "genre_artist_crossreference_genre_id_genres_id_fk"; +ALTER TABLE "wxyc_schema"."genre_artist_crossreference" ADD CONSTRAINT "genre_artist_crossreference_genre_id_genres_id_fk" + FOREIGN KEY ("genre_id") REFERENCES "wxyc_schema"."genres"("id") ON DELETE CASCADE; +--> statement-breakpoint + +-- artist_library_crossreference FKs +ALTER TABLE "wxyc_schema"."artist_library_crossreference" DROP CONSTRAINT IF EXISTS "artist_library_crossreference_artist_id_artists_id_fk"; +ALTER TABLE "wxyc_schema"."artist_library_crossreference" ADD CONSTRAINT "artist_library_crossreference_artist_id_artists_id_fk" + FOREIGN KEY ("artist_id") REFERENCES "wxyc_schema"."artists"("id") ON DELETE CASCADE; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."artist_library_crossreference" DROP CONSTRAINT IF EXISTS "artist_library_crossreference_library_id_library_id_fk"; +ALTER TABLE "wxyc_schema"."artist_library_crossreference" ADD CONSTRAINT "artist_library_crossreference_library_id_library_id_fk" + FOREIGN KEY ("library_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE CASCADE; +--> statement-breakpoint + +-- shows FKs +ALTER TABLE "wxyc_schema"."shows" DROP CONSTRAINT IF EXISTS "shows_primary_dj_id_auth_user_id_fk"; +ALTER TABLE "wxyc_schema"."shows" ADD CONSTRAINT "shows_primary_dj_id_auth_user_id_fk" + FOREIGN KEY ("primary_dj_id") REFERENCES "public"."auth_user"("id") ON DELETE SET NULL; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."shows" DROP CONSTRAINT IF EXISTS "shows_specialty_id_specialty_shows_id_fk"; +ALTER TABLE "wxyc_schema"."shows" ADD CONSTRAINT "shows_specialty_id_specialty_shows_id_fk" + FOREIGN KEY ("specialty_id") REFERENCES "wxyc_schema"."specialty_shows"("id") ON DELETE SET NULL; +--> statement-breakpoint + +-- show_djs FK (show_id → cascade, dj_id already has cascade) +ALTER TABLE "wxyc_schema"."show_djs" DROP CONSTRAINT IF EXISTS "show_djs_show_id_shows_id_fk"; +ALTER TABLE "wxyc_schema"."show_djs" ADD CONSTRAINT "show_djs_show_id_shows_id_fk" + FOREIGN KEY ("show_id") REFERENCES "wxyc_schema"."shows"("id") ON DELETE CASCADE; +--> statement-breakpoint + +-- F39: Convert all wxyc_schema timestamps to timestamptz +-- PostgreSQL preserves values when converting timestamp → timestamptz +ALTER TABLE "wxyc_schema"."shift_covers" ALTER COLUMN "shift_timestamp" TYPE timestamptz USING "shift_timestamp" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."artists" ALTER COLUMN "last_modified" TYPE timestamptz USING "last_modified" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."library" ALTER COLUMN "add_date" TYPE timestamptz USING "add_date" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."library" ALTER COLUMN "last_modified" TYPE timestamptz USING "last_modified" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."flowsheet" ALTER COLUMN "add_time" TYPE timestamptz USING "add_time" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."genres" ALTER COLUMN "last_modified" TYPE timestamptz USING "last_modified" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."reviews" ALTER COLUMN "last_modified" TYPE timestamptz USING "last_modified" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."shows" ALTER COLUMN "start_time" TYPE timestamptz USING "start_time" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."shows" ALTER COLUMN "end_time" TYPE timestamptz USING "end_time" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."specialty_shows" ALTER COLUMN "last_modified" TYPE timestamptz USING "last_modified" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."album_metadata" ALTER COLUMN "last_accessed" TYPE timestamptz USING "last_accessed" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."album_metadata" ALTER COLUMN "created_at" TYPE timestamptz USING "created_at" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."artist_metadata" ALTER COLUMN "last_accessed" TYPE timestamptz USING "last_accessed" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."artist_metadata" ALTER COLUMN "created_at" TYPE timestamptz USING "created_at" AT TIME ZONE 'America/New_York'; diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index 05edfa7..6590b7d 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1771099200000, "tag": "0029_rename_play_freq_to_rotation_bin", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1740153600000, + "tag": "0026_schema_audit_fixes", + "breakpoints": true } ] } diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 7a9bfc5..0505377 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -175,9 +175,9 @@ export const schedule = wxyc_schema.table('schedule', { day: smallint('day').notNull(), start_time: time('start_time').notNull(), show_duration: smallint('show_duration').notNull(), // In 15-minute blocs - specialty_id: integer('specialty_id').references(() => specialty_shows.id), //null for regular shows - assigned_dj_id: varchar('assigned_dj_id', { length: 255 }).references(() => user.id), - assigned_dj_id2: varchar('assigned_dj_id2', { length: 255 }).references(() => user.id), + specialty_id: integer('specialty_id').references(() => specialty_shows.id, { onDelete: 'set null' }), + assigned_dj_id: varchar('assigned_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), + assigned_dj_id2: varchar('assigned_dj_id2', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), }); //SELECT date_trunc('week', current_timestamp + timestamp '${n} weeks') + interval '${schedule.day} days' + ${schedule.time} @@ -186,11 +186,11 @@ export type NewShiftCover = InferInsertModel; export type ShiftCover = InferSelectModel; export const shift_covers = wxyc_schema.table('shift_covers', { id: serial('id').primaryKey(), - schedule_id: serial('schedule_id') + schedule_id: integer('schedule_id') .references(() => schedule.id) .notNull(), - shift_timestamp: timestamp('shift_timestamp').notNull(), //Timestamp to expire cover requests - cover_dj_id: varchar('cover_dj_id', { length: 255 }).references(() => user.id), + shift_timestamp: timestamp('shift_timestamp', { withTimezone: true }).notNull(), + cover_dj_id: varchar('cover_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), covered: boolean('covered').default(false), }); @@ -207,7 +207,7 @@ export const artists = wxyc_schema.table( code_letters: varchar('code_letters', { length: 2 }).notNull(), code_artist_number: smallint('code_artist_number').notNull(), add_date: date('add_date').defaultNow().notNull(), - last_modified: timestamp('last_modified').defaultNow().notNull(), + last_modified: timestamp('last_modified', { withTimezone: true }).defaultNow().notNull(), }, (table) => { return { @@ -247,8 +247,8 @@ export const library = wxyc_schema.table( code_number: smallint('code_number').notNull(), disc_quantity: smallint('disc_quantity').default(1).notNull(), plays: integer('plays').default(0).notNull(), - add_date: timestamp('add_date').defaultNow().notNull(), - last_modified: timestamp('last_modified').defaultNow().notNull(), + add_date: timestamp('add_date', { withTimezone: true }).defaultNow().notNull(), + last_modified: timestamp('last_modified', { withTimezone: true }).defaultNow().notNull(), }, (table) => { return { @@ -279,7 +279,7 @@ export const rotation = wxyc_schema.table( { id: serial('id').primaryKey(), //need to create an entry w/ id 0 for items not currently on rotation and items from outside the station album_id: integer('album_id') - .references(() => library.id) + .references(() => library.id, { onDelete: 'cascade' }) .notNull(), rotation_bin: freqEnum('rotation_bin').notNull(), add_date: date('add_date').defaultNow().notNull(), @@ -296,18 +296,18 @@ export type NewFSEntry = InferInsertModel; export type FSEntry = InferSelectModel; export const flowsheet = wxyc_schema.table('flowsheet', { id: serial('id').primaryKey(), - show_id: integer('show_id').references(() => shows.id), - album_id: integer('album_id').references(() => library.id), - rotation_id: integer('rotation_id').references(() => rotation.id), + show_id: integer('show_id').references(() => shows.id, { onDelete: 'set null' }), + album_id: integer('album_id').references(() => library.id, { onDelete: 'set null' }), + rotation_id: integer('rotation_id').references(() => rotation.id, { onDelete: 'set null' }), entry_type: flowsheetEntryTypeEnum('entry_type').notNull().default('track'), track_title: varchar('track_title', { length: 128 }), album_title: varchar('album_title', { length: 128 }), artist_name: varchar('artist_name', { length: 128 }), record_label: varchar('record_label', { length: 128 }), - play_order: serial('play_order').notNull(), + play_order: integer('play_order').notNull(), request_flag: boolean('request_flag').default(false).notNull(), message: varchar('message', { length: 250 }), - add_time: timestamp('add_time').defaultNow().notNull(), + add_time: timestamp('add_time', { withTimezone: true }).defaultNow().notNull(), }); export type NewGenre = InferInsertModel; @@ -318,7 +318,7 @@ export const genres = wxyc_schema.table('genres', { description: text('description'), plays: integer('plays').default(0).notNull(), add_date: date('add_date').defaultNow().notNull(), - last_modified: timestamp('last_modified').defaultNow().notNull(), + last_modified: timestamp('last_modified', { withTimezone: true }).defaultNow().notNull(), }); export type NewReview = InferInsertModel; @@ -326,12 +326,12 @@ export type Review = InferSelectModel; export const reviews = wxyc_schema.table('reviews', { id: serial('id').primaryKey(), album_id: integer('album_id') - .references(() => library.id) + .references(() => library.id, { onDelete: 'cascade' }) .notNull() .unique(), review: text('review'), add_date: date('add_date').defaultNow().notNull(), - last_modified: timestamp('last_modified').defaultNow().notNull(), + last_modified: timestamp('last_modified', { withTimezone: true }).defaultNow().notNull(), author: varchar('author', { length: 32 }), }); @@ -355,10 +355,10 @@ export const genre_artist_crossreference = wxyc_schema.table( { artist_id: integer('artist_id') .notNull() - .references(() => artists.id), + .references(() => artists.id, { onDelete: 'cascade' }), genre_id: integer('genre_id') .notNull() - .references(() => genres.id), + .references(() => genres.id, { onDelete: 'cascade' }), artist_genre_code: integer('artist_genre_code').notNull(), }, (table) => [uniqueIndex('artist_genre_key').on(table.artist_id, table.genre_id)] @@ -369,8 +369,8 @@ export type ArtistLibraryCrossreference = InferSelectModel artists.id), - library_id: integer('library_id').references(() => library.id), + artist_id: integer('artist_id').references(() => artists.id, { onDelete: 'cascade' }).notNull(), + library_id: integer('library_id').references(() => library.id, { onDelete: 'cascade' }).notNull(), }, (table) => [uniqueIndex('library_id_artist_id').on(table.artist_id, table.library_id)] ); @@ -379,25 +379,29 @@ export type NewShow = InferInsertModel; export type Show = InferSelectModel; export const shows = wxyc_schema.table('shows', { id: serial('id').primaryKey(), - primary_dj_id: varchar('primary_dj_id', { length: 255 }).references(() => user.id), - specialty_id: integer('specialty_id') //Null for regular shows - .references(() => specialty_shows.id), - show_name: varchar('show_name', { length: 128 }), //Null if not provided or specialty show - start_time: timestamp('start_time').defaultNow().notNull(), - end_time: timestamp('end_time'), + primary_dj_id: varchar('primary_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), + specialty_id: integer('specialty_id') + .references(() => specialty_shows.id, { onDelete: 'set null' }), + show_name: varchar('show_name', { length: 128 }), + start_time: timestamp('start_time', { withTimezone: true }).defaultNow().notNull(), + end_time: timestamp('end_time', { withTimezone: true }), }); export type NewShowDJ = InferInsertModel; export type ShowDJ = InferSelectModel; -export const show_djs = wxyc_schema.table('show_djs', { - show_id: integer('show_id') - .references(() => shows.id) - .notNull(), - dj_id: varchar('dj_id', { length: 255 }) - .references(() => user.id, { onDelete: 'cascade' }) - .notNull(), - active: boolean('active').default(true), -}); +export const show_djs = wxyc_schema.table( + 'show_djs', + { + show_id: integer('show_id') + .references(() => shows.id, { onDelete: 'cascade' }) + .notNull(), + dj_id: varchar('dj_id', { length: 255 }) + .references(() => user.id, { onDelete: 'cascade' }) + .notNull(), + active: boolean('active').default(true), + }, + (table) => [uniqueIndex('show_djs_show_id_dj_id_unique').on(table.show_id, table.dj_id)] +); //create entry w/ ID 0 for regular shows export type NewSpecialtyShow = InferInsertModel; @@ -407,7 +411,7 @@ export const specialty_shows = wxyc_schema.table('specialty_shows', { specialty_name: varchar('specialty_name', { length: 64 }).notNull(), description: text('description'), add_date: date('add_date').defaultNow().notNull(), - last_modified: timestamp('last_modified').defaultNow().notNull(), + last_modified: timestamp('last_modified', { withTimezone: true }).defaultNow().notNull(), }); export type LibraryArtistViewEntry = { @@ -491,8 +495,8 @@ export const album_metadata = wxyc_schema.table( // LRU cache management is_rotation: boolean('is_rotation').default(false).notNull(), - last_accessed: timestamp('last_accessed').defaultNow().notNull(), - created_at: timestamp('created_at').defaultNow().notNull(), + last_accessed: timestamp('last_accessed', { withTimezone: true }).defaultNow().notNull(), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => { return { @@ -520,8 +524,8 @@ export const artist_metadata = wxyc_schema.table( wikipedia_url: varchar('wikipedia_url', { length: 512 }), // LRU cache management - last_accessed: timestamp('last_accessed').defaultNow().notNull(), - created_at: timestamp('created_at').defaultNow().notNull(), + last_accessed: timestamp('last_accessed', { withTimezone: true }).defaultNow().notNull(), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => { return { @@ -550,7 +554,7 @@ export const anonymous_devices = pgTable( 'anonymous_devices', { id: serial('id').primaryKey(), - deviceId: varchar('device_id', { length: 255 }).notNull().unique(), + deviceId: varchar('device_id', { length: 255 }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(), blocked: boolean('blocked').notNull().default(false), From 9bcb1b80f614ab9d777669d4f8836de87f9db11a Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 10:22:44 -0800 Subject: [PATCH 2/7] fix: provide play_order on all flowsheet inserts play_order changed from serial to integer, requiring explicit values. Add getNextPlayOrder() helper that computes max(play_order)+1 per show. Use it in addTrack, startShow, endShow, and DJ join/leave notifications. --- .../controllers/flowsheet.controller.ts | 8 ++--- apps/backend/services/flowsheet.service.ts | 31 +++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index c8074c5..8b18185 100644 --- a/apps/backend/controllers/flowsheet.controller.ts +++ b/apps/backend/controllers/flowsheet.controller.ts @@ -1,6 +1,6 @@ import { Request, RequestHandler } from 'express'; import { Mutex } from 'async-mutex'; -import { NewFSEntry, FSEntry, Show, ShowDJ, library } from '@wxyc/database'; +import { FSEntry, Show, ShowDJ, library } from '@wxyc/database'; import * as flowsheet_service from '../services/flowsheet.service.js'; import { fetchAndCacheMetadata } from '../services/metadata/index.js'; @@ -159,7 +159,7 @@ export const addEntry: RequestHandler = async (req: Request { } }; +/** Get the next play_order value for a given show (max + 1, or 1 if no entries exist) */ +export const getNextPlayOrder = async (showId: number): Promise => { + const result = await db + .select({ maxOrder: max(flowsheet.play_order) }) + .from(flowsheet) + .where(eq(flowsheet.show_id, showId)); + return (result[0]?.maxOrder ?? 0) + 1; +}; + // SQL query fields (flat structure from database) const FSEntryFieldsRaw = { id: flowsheet.id, @@ -174,7 +183,7 @@ export const getEntriesByShow = async (...show_ids: number[]): Promise => { +export const addTrack = async (entry: Omit & { play_order?: number }): Promise => { /* TODO: logic for updating album playcount */ @@ -202,7 +211,15 @@ export const addTrack = async (entry: NewFSEntry): Promise => { // } // } - const response = await db.insert(flowsheet).values(entry).returning(); + // Compute play_order if not already set + if (entry.show_id != null && !entry.play_order) { + entry = { ...entry, play_order: await getNextPlayOrder(entry.show_id) }; + } + + const response = await db + .insert(flowsheet) + .values(entry as NewFSEntry) + .returning(); updateLastModified(); return response[0]; }; @@ -291,8 +308,10 @@ export const startShow = async (dj_id: string, show_name?: string, specialty_id? }) .returning(); + const startPlayOrder = await getNextPlayOrder(new_show[0].id); await db.insert(flowsheet).values({ show_id: new_show[0].id, + play_order: startPlayOrder, entry_type: 'show_start', message: `Start of Show: DJ ${dj_info.djName || dj_info.name} joined the set at ${new Date().toLocaleString( 'en-US', @@ -352,10 +371,12 @@ const createJoinNotification = async (id: string, show_id: number): Promise => { const dj_information = (await db.select().from(user).where(eq(user.id, primary_dj_id)).limit(1))[0]; const dj_name = dj_information?.djName || dj_information?.name || 'A DJ'; + const endPlayOrder = await getNextPlayOrder(currentShow.id); await db.insert(flowsheet).values({ show_id: currentShow.id, + play_order: endPlayOrder, entry_type: 'show_end', message: `End of Show: ${dj_name} left the set at ${new Date().toLocaleString('en-US', { timeZone: 'America/New_York', @@ -431,10 +454,12 @@ const createLeaveNotification = async (dj_id: string, show_id: number): Promise< const message = `${dj_name} left the set!`; + const leavePlayOrder = await getNextPlayOrder(show_id); const notification = await db .insert(flowsheet) .values({ show_id: show_id, + play_order: leavePlayOrder, entry_type: 'dj_leave', message: message, }) From eb94b0236b7b0d372330f64eddab6e2c2b918816 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 14:20:47 -0800 Subject: [PATCH 3/7] style: format files with Prettier --- shared/database/src/schema.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 0505377..4bc19e5 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -369,8 +369,12 @@ export type ArtistLibraryCrossreference = InferSelectModel artists.id, { onDelete: 'cascade' }).notNull(), - library_id: integer('library_id').references(() => library.id, { onDelete: 'cascade' }).notNull(), + artist_id: integer('artist_id') + .references(() => artists.id, { onDelete: 'cascade' }) + .notNull(), + library_id: integer('library_id') + .references(() => library.id, { onDelete: 'cascade' }) + .notNull(), }, (table) => [uniqueIndex('library_id_artist_id').on(table.artist_id, table.library_id)] ); @@ -380,8 +384,7 @@ export type Show = InferSelectModel; export const shows = wxyc_schema.table('shows', { id: serial('id').primaryKey(), primary_dj_id: varchar('primary_dj_id', { length: 255 }).references(() => user.id, { onDelete: 'set null' }), - specialty_id: integer('specialty_id') - .references(() => specialty_shows.id, { onDelete: 'set null' }), + specialty_id: integer('specialty_id').references(() => specialty_shows.id, { onDelete: 'set null' }), show_name: varchar('show_name', { length: 128 }), start_time: timestamp('start_time', { withTimezone: true }).defaultNow().notNull(), end_time: timestamp('end_time', { withTimezone: true }), From a1c9cff32112ffbcbe6b4046d75b226354492eee Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 15:16:38 -0800 Subject: [PATCH 4/7] fix: add missing journal entry for anonymous_devices migration The 0024_anonymous_devices.sql migration file existed but had no corresponding journal entry, so drizzle-kit never executed it. The 0026_schema_audit_fixes migration then failed with "relation anonymous_devices does not exist" when trying to drop a constraint on the missing table. Add journal entry at idx 27 for 0024_anonymous_devices (before the schema audit fixes at idx 28) so the table is created first. --- shared/database/src/migrations/meta/_journal.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index 6590b7d..92df957 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -173,6 +173,13 @@ { "idx": 27, "version": "7", + "when": 1769067600000, + "tag": "0024_anonymous_devices", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", "when": 1740153600000, "tag": "0026_schema_audit_fixes", "breakpoints": true From 15237b73c976aa8fb0b769d3f02bc1f81726cfbd Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 15:16:54 -0800 Subject: [PATCH 5/7] fix: propagate db-init container failures in CI Use --abort-on-container-exit with docker compose up ci-db-init so that migration failures are caught immediately instead of silently continuing with an uninitialized database. --- scripts/ci-env.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci-env.sh b/scripts/ci-env.sh index d446dc0..7b4a635 100755 --- a/scripts/ci-env.sh +++ b/scripts/ci-env.sh @@ -51,8 +51,8 @@ fi # Start database $COMPOSE_CMD up -d ci-db -# Run database initialization -$COMPOSE_CMD up ci-db-init +# Run database initialization (--abort-on-container-exit propagates failures) +$COMPOSE_CMD up --abort-on-container-exit ci-db-init # Start auth and backend services # Environment variables are already exported above, Docker Compose will inherit them From 26ac171d67514d60224dd2e5d09e6e9de3159c9b Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 15:27:14 -0800 Subject: [PATCH 6/7] fix: use global play_order sequence instead of per-show getNextPlayOrder() was scoped per-show (WHERE show_id = X), making play_order values non-globally-unique. Queries use ORDER BY play_order DESC globally, so the sequence must be monotonically increasing across all shows to preserve correct ordering. --- apps/backend/services/flowsheet.service.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index a8adeba..590b596 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -40,12 +40,9 @@ const updateLastModified = () => { } }; -/** Get the next play_order value for a given show (max + 1, or 1 if no entries exist) */ -export const getNextPlayOrder = async (showId: number): Promise => { - const result = await db - .select({ maxOrder: max(flowsheet.play_order) }) - .from(flowsheet) - .where(eq(flowsheet.show_id, showId)); +/** Get the next play_order value (global max + 1, or 1 if no entries exist) */ +export const getNextPlayOrder = async (): Promise => { + const result = await db.select({ maxOrder: max(flowsheet.play_order) }).from(flowsheet); return (result[0]?.maxOrder ?? 0) + 1; }; @@ -213,7 +210,7 @@ export const addTrack = async (entry: Omit & { play_or // Compute play_order if not already set if (entry.show_id != null && !entry.play_order) { - entry = { ...entry, play_order: await getNextPlayOrder(entry.show_id) }; + entry = { ...entry, play_order: await getNextPlayOrder() }; } const response = await db @@ -308,7 +305,7 @@ export const startShow = async (dj_id: string, show_name?: string, specialty_id? }) .returning(); - const startPlayOrder = await getNextPlayOrder(new_show[0].id); + const startPlayOrder = await getNextPlayOrder(); await db.insert(flowsheet).values({ show_id: new_show[0].id, play_order: startPlayOrder, @@ -371,7 +368,7 @@ const createJoinNotification = async (id: string, show_id: number): Promise => { const dj_information = (await db.select().from(user).where(eq(user.id, primary_dj_id)).limit(1))[0]; const dj_name = dj_information?.djName || dj_information?.name || 'A DJ'; - const endPlayOrder = await getNextPlayOrder(currentShow.id); + const endPlayOrder = await getNextPlayOrder(); await db.insert(flowsheet).values({ show_id: currentShow.id, play_order: endPlayOrder, @@ -454,7 +451,7 @@ const createLeaveNotification = async (dj_id: string, show_id: number): Promise< const message = `${dj_name} left the set!`; - const leavePlayOrder = await getNextPlayOrder(show_id); + const leavePlayOrder = await getNextPlayOrder(); const notification = await db .insert(flowsheet) .values({ From c99279f4dd1fd0b5222b26dbfd991abec104a95d Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 15:37:44 -0800 Subject: [PATCH 7/7] fix: drop and recreate library_artist_view around library column type changes PostgreSQL cannot alter a column's type when a view depends on it. The migration now drops library_artist_view before converting library.add_date and library.last_modified to timestamptz, then recreates the view afterward. --- .../migrations/0026_schema_audit_fixes.sql | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/shared/database/src/migrations/0026_schema_audit_fixes.sql b/shared/database/src/migrations/0026_schema_audit_fixes.sql index a3ffd9d..d377855 100644 --- a/shared/database/src/migrations/0026_schema_audit_fixes.sql +++ b/shared/database/src/migrations/0026_schema_audit_fixes.sql @@ -137,6 +137,10 @@ ALTER TABLE "wxyc_schema"."shift_covers" ALTER COLUMN "shift_timestamp" TYPE tim --> statement-breakpoint ALTER TABLE "wxyc_schema"."artists" ALTER COLUMN "last_modified" TYPE timestamptz USING "last_modified" AT TIME ZONE 'America/New_York'; --> statement-breakpoint + +-- Drop views that depend on library columns before altering their types +DROP VIEW IF EXISTS "wxyc_schema"."library_artist_view"; +--> statement-breakpoint ALTER TABLE "wxyc_schema"."library" ALTER COLUMN "add_date" TYPE timestamptz USING "add_date" AT TIME ZONE 'America/New_York'; --> statement-breakpoint ALTER TABLE "wxyc_schema"."library" ALTER COLUMN "last_modified" TYPE timestamptz USING "last_modified" AT TIME ZONE 'America/New_York'; @@ -160,3 +164,25 @@ ALTER TABLE "wxyc_schema"."album_metadata" ALTER COLUMN "created_at" TYPE timest ALTER TABLE "wxyc_schema"."artist_metadata" ALTER COLUMN "last_accessed" TYPE timestamptz USING "last_accessed" AT TIME ZONE 'America/New_York'; --> statement-breakpoint ALTER TABLE "wxyc_schema"."artist_metadata" ALTER COLUMN "created_at" TYPE timestamptz USING "created_at" AT TIME ZONE 'America/New_York'; +--> statement-breakpoint + +-- Recreate library_artist_view after altering library column types +CREATE VIEW "wxyc_schema"."library_artist_view" AS +SELECT "wxyc_schema"."library"."id", + "wxyc_schema"."artists"."code_letters", + "wxyc_schema"."artists"."code_artist_number", + "wxyc_schema"."library"."code_number", + "wxyc_schema"."artists"."artist_name", + "wxyc_schema"."library"."album_title", + "wxyc_schema"."format"."format_name", + "wxyc_schema"."genres"."genre_name", + "wxyc_schema"."rotation"."rotation_bin", + "wxyc_schema"."library"."add_date", + "wxyc_schema"."library"."label" +FROM "wxyc_schema"."library" + INNER JOIN "wxyc_schema"."artists" ON "wxyc_schema"."artists"."id" = "wxyc_schema"."library"."artist_id" + INNER JOIN "wxyc_schema"."format" ON "wxyc_schema"."format"."id" = "wxyc_schema"."library"."format_id" + INNER JOIN "wxyc_schema"."genres" ON "wxyc_schema"."genres"."id" = "wxyc_schema"."library"."genre_id" + LEFT JOIN "wxyc_schema"."rotation" + ON "wxyc_schema"."rotation"."album_id" = "wxyc_schema"."library"."id" + AND ("wxyc_schema"."rotation"."kill_date" < CURRENT_DATE OR "wxyc_schema"."rotation"."kill_date" IS NULL);