diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index c8074c56..8b181850 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 (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; +}; + // SQL query fields (flat structure from database) const FSEntryFieldsRaw = { id: flowsheet.id, @@ -174,7 +180,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 +208,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() }; + } + + const response = await db + .insert(flowsheet) + .values(entry as NewFSEntry) + .returning(); updateLastModified(); return response[0]; }; @@ -291,8 +305,10 @@ export const startShow = async (dj_id: string, show_name?: string, specialty_id? }) .returning(); + const startPlayOrder = await getNextPlayOrder(); 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 +368,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(); 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 +451,12 @@ const createLeaveNotification = async (dj_id: string, show_id: number): Promise< const message = `${dj_name} left the set!`; + const leavePlayOrder = await getNextPlayOrder(); const notification = await db .insert(flowsheet) .values({ show_id: show_id, + play_order: leavePlayOrder, entry_type: 'dj_leave', message: message, }) diff --git a/scripts/ci-env.sh b/scripts/ci-env.sh index d446dc09..7b4a6359 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 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 00000000..d377855c --- /dev/null +++ b/shared/database/src/migrations/0026_schema_audit_fixes.sql @@ -0,0 +1,188 @@ +-- 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 + +-- 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'; +--> 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'; +--> 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); diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index 05edfa78..92df957a 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -169,6 +169,20 @@ "when": 1771099200000, "tag": "0029_rename_play_freq_to_rotation_bin", "breakpoints": true + }, + { + "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 } ] } diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 7a9bfc5e..4bc19e5d 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,12 @@ 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 +383,28 @@ 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 +414,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 +498,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 +527,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 +557,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),