diff --git a/apps/backend/app.ts b/apps/backend/app.ts index 14a98c1..b6b32c7 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -5,6 +5,7 @@ import { parse as parse_yaml } from 'yaml'; import swaggerContent from './app.yaml'; import { dj_route } from './routes/djs.route.js'; import { flowsheet_route } from './routes/flowsheet.route.js'; +import { flowsheet_v2_route } from './routes/flowsheet.v2.route.js'; import { library_route } from './routes/library.route.js'; import { schedule_route } from './routes/schedule.route.js'; import { events_route } from './routes/events.route.js'; @@ -39,6 +40,8 @@ app.use('/library', library_route); app.use('/flowsheet', flowsheet_route); +app.use('/v2/flowsheet', flowsheet_v2_route); + app.use('/djs', dj_route); app.use('/request', request_line_route); @@ -73,7 +76,3 @@ const server = app.listen(port, () => { }); server.setTimeout(5000); - -// Force rebuild for CI - -// Force rebuild for CI diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index 5c1c8bc..d73b6cf 100644 --- a/apps/backend/controllers/flowsheet.controller.ts +++ b/apps/backend/controllers/flowsheet.controller.ts @@ -28,6 +28,7 @@ export interface IFSEntryMetadata { } export interface IFSEntry extends FSEntry { + entry_type: string; rotation_play_freq: string | null; metadata: IFSEntryMetadata; } diff --git a/apps/backend/controllers/flowsheet.v2.controller.ts b/apps/backend/controllers/flowsheet.v2.controller.ts new file mode 100644 index 0000000..ab27a64 --- /dev/null +++ b/apps/backend/controllers/flowsheet.v2.controller.ts @@ -0,0 +1,33 @@ +import { RequestHandler } from 'express'; +import * as flowsheet_service from '../services/flowsheet.service.js'; + +/** + * GET /v2/flowsheet/playlist + * Get show info with entries in discriminated union format + */ +export const getShowInfo: RequestHandler = async (req, res, next) => { + const showId = parseInt(req.query.show_id); + + if (isNaN(showId)) { + res.status(400).json({ message: 'Missing or invalid show_id parameter' }); + return; + } + + try { + const showInfo = await flowsheet_service.getPlaylist(showId); + + // Transform entries to V2 discriminated union format + const v2Entries = showInfo.entries.map((entry) => + flowsheet_service.transformToV2(entry as Parameters[0]) + ); + + res.status(200).json({ + ...showInfo, + entries: v2Entries, + }); + } catch (e) { + console.error('Error: Failed to retrieve playlist'); + console.error(e); + next(e); + } +}; diff --git a/apps/backend/middleware/legacy/flowsheet.mirror.ts b/apps/backend/middleware/legacy/flowsheet.mirror.ts index f9c81a3..4111c7d 100644 --- a/apps/backend/middleware/legacy/flowsheet.mirror.ts +++ b/apps/backend/middleware/legacy/flowsheet.mirror.ts @@ -150,25 +150,50 @@ const getAddEntrySQL = async (req: Request, entry: FSEntry) => { AND STOP_TIME = 0;` ); - if (entry?.message && entry.message.trim() !== "") { - let message = entry.message.trim(); + // Determine legacy entry type code based on entry_type field (if available) or message patterns + // Legacy type codes: 0-4=rotation tracks, 6=library, 7=talkset, 8=breakpoint, 9=start, 10=end + const entryType = entry.entry_type; + + // Non-track entries (messages, events, etc.) + if (entryType === 'show_start' || entryType === 'show_end' || + entryType === 'dj_join' || entryType === 'dj_leave' || + entryType === 'talkset' || entryType === 'breakpoint' || entryType === 'message' || + (entry?.message && entry.message.trim() !== "" && entryType !== 'track')) { + + let message = entry.message?.trim() ?? ''; let entryTypeCode = 7; // Default to talkset let nowPlayingFlag = 0; let startTime = 0; - - // Detect the type of message entry - if (message.toLowerCase().includes("breakpoint")) { - entryTypeCode = 8; // Breakpoint - message = message.toUpperCase(); - } else if (message.toLowerCase().includes("start of show") || message.toLowerCase().includes("signed on")) { - entryTypeCode = 9; // Start of show + + // Map entry_type to legacy type codes + if (entryType === 'show_start') { + entryTypeCode = 9; startTime = startMs; - } else if (message.toLowerCase().includes("end of show") || message.toLowerCase().includes("signed off")) { - entryTypeCode = 10; // End of show + } else if (entryType === 'show_end') { + entryTypeCode = 10; startTime = startMs; - } else { - // Talkset - format as "------ talkset -------" + } else if (entryType === 'dj_join' || entryType === 'dj_leave') { + entryTypeCode = 7; // Map to talkset in legacy + } else if (entryType === 'talkset' || entryType === 'message') { + entryTypeCode = 7; message = "------ talkset -------"; + } else if (entryType === 'breakpoint') { + entryTypeCode = 8; + message = message.toUpperCase() || 'BREAKPOINT'; + } else { + // Fallback to pattern matching for backwards compatibility + if (message.toLowerCase().includes("breakpoint")) { + entryTypeCode = 8; + message = message.toUpperCase(); + } else if (message.toLowerCase().includes("start of show") || message.toLowerCase().includes("signed on")) { + entryTypeCode = 9; + startTime = startMs; + } else if (message.toLowerCase().includes("end of show") || message.toLowerCase().includes("signed off")) { + entryTypeCode = 10; + startTime = startMs; + } else { + message = "------ talkset -------"; + } } statements.push( @@ -201,6 +226,7 @@ const getAddEntrySQL = async (req: Request, entry: FSEntry) => { '');` // BMI_COMPOSER ); } else { + // Track entries // Determine entry type code based on rotation and library IDs // Type codes: 1-4 for different rotation types, 6 for library, 0 for manual/unknown let entryTypeCode = 0; @@ -211,7 +237,7 @@ const getAddEntrySQL = async (req: Request, entry: FSEntry) => { } else if (entry.album_id && entry.album_id > 0) { entryTypeCode = 6; // Library entry } - + statements.push( `INSERT INTO ${FLOWSHEET_ENTRY_TABLE} (ID, ARTIST_NAME, ARTIST_ID, SONG_TITLE, RELEASE_TITLE, RELEASE_FORMAT_ID, diff --git a/apps/backend/routes/flowsheet.v2.route.ts b/apps/backend/routes/flowsheet.v2.route.ts new file mode 100644 index 0000000..3f8a9a5 --- /dev/null +++ b/apps/backend/routes/flowsheet.v2.route.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; +import * as flowsheetV2Controller from "../controllers/flowsheet.v2.controller.js"; + +export const flowsheet_v2_route = Router(); + +// V2 playlist returns entries in discriminated union format +flowsheet_v2_route.get("/playlist", flowsheetV2Controller.getShowInfo); diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index d90c310..2b35929 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -45,6 +45,7 @@ const FSEntryFieldsRaw = { id: flowsheet.id, show_id: flowsheet.show_id, album_id: flowsheet.album_id, + entry_type: flowsheet.entry_type, artist_name: flowsheet.artist_name, album_title: flowsheet.album_title, track_title: flowsheet.track_title, @@ -74,6 +75,7 @@ type FSEntryRaw = { id: number; show_id: number | null; album_id: number | null; + entry_type: string; artist_name: string | null; album_title: string | null; track_title: string | null; @@ -101,6 +103,7 @@ const transformToIFSEntry = (raw: FSEntryRaw): IFSEntry => ({ id: raw.id, show_id: raw.show_id, album_id: raw.album_id, + entry_type: raw.entry_type, artist_name: raw.artist_name, album_title: raw.album_title, track_title: raw.track_title, @@ -290,6 +293,7 @@ export const startShow = async (dj_id: string, show_name?: string, specialty_id? await db.insert(flowsheet).values({ show_id: new_show[0].id, + entry_type: 'show_start', message: `Start of Show: DJ ${dj_info.djName || dj_info.name} joined the set at ${new Date().toLocaleString('en-US', { timeZone: 'America/New_York', })}`, @@ -349,6 +353,7 @@ const createJoinNotification = async (id: string, show_id: number): Promise => { await db.insert(flowsheet).values({ show_id: currentShow.id, + entry_type: 'show_end', message: `End of Show: ${dj_name} left the set at ${new Date().toLocaleString('en-US', { timeZone: 'America/New_York', })}`, @@ -426,6 +432,7 @@ const createLeaveNotification = async (dj_id: string, show_id: number): Promise< .insert(flowsheet) .values({ show_id: show_id, + entry_type: 'dj_leave', message: message, }) .returning(); @@ -567,3 +574,114 @@ export const getPlaylist = async (show_id: number): Promise => { entries: entries, }; }; + +/** + * Transform a V1 flowsheet entry to V2 discriminated union format. + * Removes irrelevant fields based on entry_type for cleaner API responses. + */ +export const transformToV2 = (entry: IFSEntry): Record => { + const baseFields = { + id: entry.id, + show_id: entry.show_id, + play_order: entry.play_order, + add_time: entry.add_time, + entry_type: entry.entry_type, + }; + + switch (entry.entry_type) { + case 'track': + return { + ...baseFields, + album_id: entry.album_id, + rotation_id: entry.rotation_id, + artist_name: entry.artist_name, + album_title: entry.album_title, + track_title: entry.track_title, + record_label: entry.record_label, + request_flag: entry.request_flag, + rotation_play_freq: entry.rotation_play_freq, + artwork_url: entry.artwork_url, + discogs_url: entry.discogs_url, + release_year: entry.release_year, + spotify_url: entry.spotify_url, + apple_music_url: entry.apple_music_url, + youtube_music_url: entry.youtube_music_url, + bandcamp_url: entry.bandcamp_url, + soundcloud_url: entry.soundcloud_url, + artist_bio: entry.artist_bio, + artist_wikipedia_url: entry.artist_wikipedia_url, + }; + + case 'show_start': + case 'show_end': { + // Parse DJ name and timestamp from message + // Format: "Start of Show: DJ {name} joined the set at {timestamp}" + // Format: "End of Show: {name} left the set at {timestamp}" + const message = entry.message || ''; + let dj_name = ''; + let timestamp = ''; + + if (entry.entry_type === 'show_start') { + const match = message.match(/^Start of Show: DJ (.+) joined the set at (.+)$/); + if (match) { + dj_name = match[1]; + timestamp = match[2]; + } + } else { + const match = message.match(/^End of Show: (.+) left the set at (.+)$/); + if (match) { + dj_name = match[1]; + timestamp = match[2]; + } + } + + return { + ...baseFields, + dj_name, + timestamp, + }; + } + + case 'dj_join': + case 'dj_leave': { + // Parse DJ name from message + // Format: "{name} joined the set!" or "{name} left the set!" + const message = entry.message || ''; + let dj_name = ''; + + if (entry.entry_type === 'dj_join') { + const match = message.match(/^(.+) joined the set!$/); + if (match) { + dj_name = match[1]; + } + } else { + const match = message.match(/^(.+) left the set!$/); + if (match) { + dj_name = match[1]; + } + } + + return { + ...baseFields, + dj_name, + }; + } + + case 'talkset': + case 'message': + return { + ...baseFields, + message: entry.message, + }; + + case 'breakpoint': + return { + ...baseFields, + message: entry.message, + }; + + default: + // Fallback for unknown types - return all fields + return entry as Record; + } +}; diff --git a/shared/database/src/index.ts b/shared/database/src/index.ts index d9f4882..a6127d0 100644 --- a/shared/database/src/index.ts +++ b/shared/database/src/index.ts @@ -1,2 +1,3 @@ export * from "./client.js"; -export * from "./schema.js"; \ No newline at end of file +export * from "./schema.js"; +export * from "./types/index.js"; \ No newline at end of file diff --git a/shared/database/src/migrations/0024_flowsheet_entry_type.sql b/shared/database/src/migrations/0024_flowsheet_entry_type.sql new file mode 100644 index 0000000..f5bfe76 --- /dev/null +++ b/shared/database/src/migrations/0024_flowsheet_entry_type.sql @@ -0,0 +1,34 @@ +-- Create flowsheet entry type enum +CREATE TYPE "wxyc_schema"."flowsheet_entry_type" AS ENUM ( + 'track', + 'show_start', + 'show_end', + 'dj_join', + 'dj_leave', + 'talkset', + 'breakpoint', + 'message' +); + +-- Add entry_type column (nullable initially for backfill) +ALTER TABLE "wxyc_schema"."flowsheet" +ADD COLUMN "entry_type" "wxyc_schema"."flowsheet_entry_type"; + +-- Backfill existing data based on message patterns +UPDATE "wxyc_schema"."flowsheet" +SET entry_type = CASE + WHEN message IS NULL OR message = '' THEN 'track'::"wxyc_schema"."flowsheet_entry_type" + WHEN message LIKE 'Start of Show:%' THEN 'show_start'::"wxyc_schema"."flowsheet_entry_type" + WHEN message LIKE 'End of Show:%' THEN 'show_end'::"wxyc_schema"."flowsheet_entry_type" + WHEN message LIKE '% joined the set!' THEN 'dj_join'::"wxyc_schema"."flowsheet_entry_type" + WHEN message LIKE '% left the set!' THEN 'dj_leave'::"wxyc_schema"."flowsheet_entry_type" + ELSE 'message'::"wxyc_schema"."flowsheet_entry_type" -- Legacy messages default to 'message' type +END; + +-- Make NOT NULL with default for new entries +ALTER TABLE "wxyc_schema"."flowsheet" +ALTER COLUMN "entry_type" SET NOT NULL, +ALTER COLUMN "entry_type" SET DEFAULT 'track'; + +-- Add index for filtering by entry type +CREATE INDEX "flowsheet_entry_type_idx" ON "wxyc_schema"."flowsheet" ("entry_type"); diff --git a/shared/database/src/migrations/meta/0024_snapshot.json b/shared/database/src/migrations/meta/0024_snapshot.json index 04d67a8..6554c34 100644 --- a/shared/database/src/migrations/meta/0024_snapshot.json +++ b/shared/database/src/migrations/meta/0024_snapshot.json @@ -1,6 +1,6 @@ { - "id": "b546a947-e2be-4c4f-802f-3ed45c3f334b", - "prevId": "d6f81c6c-fc0b-44d7-9d07-d4a7646446a2", + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "prevId": "335ff99b-a9d0-49ec-a275-2bf4e9b2edf7", "version": "7", "dialect": "postgresql", "tables": { @@ -855,6 +855,14 @@ "primaryKey": false, "notNull": false }, + "entry_type": { + "name": "entry_type", + "type": "flowsheet_entry_type", + "typeSchema": "wxyc_schema", + "primaryKey": false, + "notNull": true, + "default": "'track'" + }, "track_title": { "name": "track_title", "type": "varchar(128)", @@ -906,7 +914,23 @@ "default": "now()" } }, - "indexes": {}, + "indexes": { + "flowsheet_entry_type_idx": { + "name": "flowsheet_entry_type_idx", + "columns": [ + { + "expression": "entry_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "flowsheet_show_id_shows_id_fk": { "name": "flowsheet_show_id_shows_id_fk", @@ -2330,6 +2354,13 @@ "primaryKey": false, "notNull": true, "default": "'modern-light'" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false } }, "indexes": { @@ -2371,6 +2402,60 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_activity": { + "name": "user_activity", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_auth_user_id_fk": { + "name": "user_activity_user_id_auth_user_id_fk", + "tableFrom": "user_activity", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.auth_verification": { "name": "auth_verification", "schema": "", @@ -2433,6 +2518,20 @@ "M", "H" ] + }, + "wxyc_schema.flowsheet_entry_type": { + "name": "flowsheet_entry_type", + "schema": "wxyc_schema", + "values": [ + "track", + "show_start", + "show_end", + "dj_join", + "dj_leave", + "talkset", + "breakpoint", + "message" + ] } }, "schemas": { diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index cc1a23e..05d5c99 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -152,8 +152,8 @@ { "idx": 24, "version": "7", - "when": 1768890679949, - "tag": "0024_anonymous_devices", + "when": 1769067600000, + "tag": "0024_flowsheet_entry_type", "breakpoints": true }, { diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 4703f15..1962f6f 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -263,6 +263,10 @@ export const library = wxyc_schema.table( export type NewRotationRelease = InferInsertModel; export type RotationRelease = InferSelectModel; export const freqEnum = pgEnum('freq_enum', ['S', 'L', 'M', 'H']); + +export const flowsheetEntryTypeEnum = wxyc_schema.enum('flowsheet_entry_type', [ + 'track', 'show_start', 'show_end', 'dj_join', 'dj_leave', 'talkset', 'breakpoint', 'message' +]); export const rotation = wxyc_schema.table( 'rotation', { @@ -288,6 +292,7 @@ export const flowsheet = wxyc_schema.table('flowsheet', { show_id: integer('show_id').references(() => shows.id), album_id: integer('album_id').references(() => library.id), rotation_id: integer('rotation_id').references(() => rotation.id), + 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 }), diff --git a/shared/database/src/types/flowsheet.types.ts b/shared/database/src/types/flowsheet.types.ts new file mode 100644 index 0000000..9640ee4 --- /dev/null +++ b/shared/database/src/types/flowsheet.types.ts @@ -0,0 +1,112 @@ +/** + * Flowsheet Entry Types - Discriminated Union for V2 API + * + * Entry types represent different kinds of flowsheet entries: + * - track: Song play + * - show_start: Show begins + * - show_end: Show ends + * - dj_join: DJ joins show + * - dj_leave: DJ leaves show + * - talkset: DJ talk segment (announcements, station ID, etc.) + * - breakpoint: Hour marker (top of hour transitions) + * - message: Custom message (arbitrary user text) + */ + +export type FlowsheetEntryType = + | 'track' + | 'show_start' + | 'show_end' + | 'dj_join' + | 'dj_leave' + | 'talkset' + | 'breakpoint' + | 'message'; + +/** Base fields shared by all entry types */ +interface BaseEntry { + id: number; + show_id: number | null; + play_order: number; + add_time: Date; +} + +/** Track entry - a song that was played */ +export interface TrackEntryV2 extends BaseEntry { + entry_type: 'track'; + album_id: number | null; + rotation_id: number | null; + artist_name: string | null; + album_title: string | null; + track_title: string | null; + record_label: string | null; + request_flag: boolean; + rotation_play_freq: string | null; + // Album metadata from cache + artwork_url: string | null; + discogs_url: string | null; + release_year: number | null; + spotify_url: string | null; + apple_music_url: string | null; + youtube_music_url: string | null; + bandcamp_url: string | null; + soundcloud_url: string | null; + // Artist metadata from cache + artist_bio: string | null; + artist_wikipedia_url: string | null; +} + +/** Show start event - when a show begins */ +export interface ShowStartEntryV2 extends BaseEntry { + entry_type: 'show_start'; + dj_name: string; + timestamp: string; +} + +/** Show end event - when a show ends */ +export interface ShowEndEntryV2 extends BaseEntry { + entry_type: 'show_end'; + dj_name: string; + timestamp: string; +} + +/** DJ join event - when a DJ joins an active show */ +export interface DJJoinEntryV2 extends BaseEntry { + entry_type: 'dj_join'; + dj_name: string; +} + +/** DJ leave event - when a DJ leaves an active show */ +export interface DJLeaveEntryV2 extends BaseEntry { + entry_type: 'dj_leave'; + dj_name: string; +} + +/** Talkset entry - DJ talk segment, announcements, station ID */ +export interface TalksetEntryV2 extends BaseEntry { + entry_type: 'talkset'; + message: string; +} + +/** Breakpoint entry - hour marker, top of hour transitions */ +export interface BreakpointEntryV2 extends BaseEntry { + entry_type: 'breakpoint'; + message: string | null; +} + +/** Message entry - custom/arbitrary message */ +export interface MessageEntryV2 extends BaseEntry { + entry_type: 'message'; + message: string; +} + +/** Union type of all V2 flowsheet entries */ +export type FlowsheetEntryV2 = + | TrackEntryV2 + | ShowStartEntryV2 + | ShowEndEntryV2 + | DJJoinEntryV2 + | DJLeaveEntryV2 + | TalksetEntryV2 + | BreakpointEntryV2 + | MessageEntryV2; + diff --git a/shared/database/src/types/index.ts b/shared/database/src/types/index.ts new file mode 100644 index 0000000..8806f56 --- /dev/null +++ b/shared/database/src/types/index.ts @@ -0,0 +1 @@ +export * from './flowsheet.types.js'; diff --git a/tests/integration/flowsheet.spec.js b/tests/integration/flowsheet.spec.js index 0ba1e5d..ab916e4 100644 --- a/tests/integration/flowsheet.spec.js +++ b/tests/integration/flowsheet.spec.js @@ -723,3 +723,99 @@ describe('Retrieve Playlist Object', () => { expect(new Date(playlist.body.date)).toBeInstanceOf(Date); }); }); + +/* + * V2 API - Playlist with Discriminated Union Format + */ +describe('V2 Playlist - Discriminated Union Format', () => { + beforeEach(async () => { + // setup show + const res = await fls_util.join_show(global.primary_dj_id, global.access_token); + const body = await res.json(); + global.CurrentShowID = body.id; + + await fls_util.join_show(global.secondary_dj_id, global.access_token); + + // Insert entry for show + await request + .post('/flowsheet') + .set('Authorization', global.access_token) + .send({ + album_id: 3, //Jockstrap - I Love You Jennifer B + track_title: 'Debra', + }) + .expect(200); + + await fls_util.leave_show(global.primary_dj_id, global.access_token); + }); + + test('returns entries with entry_type discriminated union', async () => { + const playlist = await request + .get('/v2/flowsheet/playlist') + .query({ show_id: global.CurrentShowID }) + .send() + .expect(200); + + expect(playlist.body.show_djs).toEqual([ + { id: global.primary_dj_id, dj_name: 'Test dj1' }, + { id: global.secondary_dj_id, dj_name: 'Test dj2' }, + ]); + + // All entries should have entry_type + playlist.body.entries.forEach((entry) => { + expect(entry.entry_type).toBeDefined(); + expect([ + 'track', + 'show_start', + 'show_end', + 'dj_join', + 'dj_leave', + 'talkset', + 'breakpoint', + 'message', + ]).toContain(entry.entry_type); + }); + + // Track entries should not have message field + const trackEntry = playlist.body.entries.find((e) => e.entry_type === 'track'); + expect(trackEntry).toBeDefined(); + expect(trackEntry.artist_name).toBeDefined(); + expect(trackEntry.message).toBeUndefined(); + + // Show start/end entries should have dj_name and timestamp, not track fields + const showStartEntry = playlist.body.entries.find((e) => e.entry_type === 'show_start'); + expect(showStartEntry).toBeDefined(); + expect(showStartEntry.dj_name).toBeDefined(); + expect(showStartEntry.timestamp).toBeDefined(); + expect(showStartEntry.artist_name).toBeUndefined(); + }); +}); + +describe('V1 API - entry_type field', () => { + beforeEach(async () => { + await fls_util.join_show(global.primary_dj_id, global.access_token); + }); + + afterEach(async () => { + await fls_util.leave_show(global.primary_dj_id, global.access_token); + }); + + test('V1 API returns entry_type field (additive change)', async () => { + // Add a track via V1 API + const addRes = await request + .post('/flowsheet') + .set('Authorization', global.access_token) + .send({ + album_id: 1, + track_title: 'Carry the Zero', + }) + .expect(200); + + // V1 response should now include entry_type (additive change) + expect(addRes.body.entry_type).toBe('track'); + + // V1 GET should also include entry_type + const getRes = await request.get('/flowsheet').query({ limit: 1 }).expect(200); + expect(getRes.body[0].entry_type).toBeDefined(); + }); +}); diff --git a/tests/mocks/database.mock.ts b/tests/mocks/database.mock.ts index 142a867..9119aee 100644 --- a/tests/mocks/database.mock.ts +++ b/tests/mocks/database.mock.ts @@ -61,6 +61,14 @@ export const genres = {}; export const format = {}; export const rotation = {}; export const library_artist_view = {}; +export const flowsheet = { id: 'id', show_id: 'show_id', album_id: 'album_id', entry_type: 'entry_type', track_title: 'track_title', album_title: 'album_title', artist_name: 'artist_name', record_label: 'record_label', rotation_id: 'rotation_id', play_order: 'play_order', request_flag: 'request_flag', message: 'message', add_time: 'add_time' }; +export const shows = {}; +export const show_djs = {}; +export const user = {}; +export const specialty_shows = {}; + +// Mock enum +export const flowsheetEntryTypeEnum = () => ({}); // Mock types export type AnonymousDevice = { @@ -78,3 +86,24 @@ export type AlbumMetadata = Record; export type ArtistMetadata = Record; export type NewAlbumMetadata = Record; export type NewArtistMetadata = Record; + +export type FSEntry = { + id: number; + show_id: number | null; + album_id: number | null; + rotation_id: number | null; + entry_type: string; + track_title: string | null; + album_title: string | null; + artist_name: string | null; + record_label: string | null; + play_order: number; + request_flag: boolean; + message: string | null; + add_time: Date; +}; + +export type NewFSEntry = Partial; +export type Show = Record; +export type ShowDJ = Record; +export type User = Record; diff --git a/tests/unit/services/flowsheet.service.test.ts b/tests/unit/services/flowsheet.service.test.ts new file mode 100644 index 0000000..c15e0de --- /dev/null +++ b/tests/unit/services/flowsheet.service.test.ts @@ -0,0 +1,400 @@ +import { transformToV2 } from '../../../apps/backend/services/flowsheet.service'; +import { IFSEntry } from '../../../apps/backend/controllers/flowsheet.controller'; + +// Helper to create a base entry with common fields +const createBaseEntry = (overrides: Partial = {}): IFSEntry => ({ + id: 1, + show_id: 100, + album_id: null, + rotation_id: null, + entry_type: 'track', + track_title: null, + album_title: null, + artist_name: null, + record_label: null, + play_order: 1, + request_flag: false, + message: null, + add_time: new Date('2024-01-15T12:00:00Z'), + rotation_play_freq: null, + artwork_url: null, + discogs_url: null, + release_year: null, + spotify_url: null, + apple_music_url: null, + youtube_music_url: null, + bandcamp_url: null, + soundcloud_url: null, + artist_bio: null, + artist_wikipedia_url: null, + ...overrides, +}); + +describe('flowsheet.service', () => { + describe('transformToV2', () => { + describe('track entries', () => { + it('includes track-specific fields for track entry_type', () => { + const entry = createBaseEntry({ + entry_type: 'track', + artist_name: 'Built to Spill', + album_title: 'Keep it Like a Secret', + track_title: 'Carry the Zero', + record_label: 'Warner Bros', + album_id: 1, + rotation_id: 5, + request_flag: true, + rotation_play_freq: 'H', + artwork_url: 'https://example.com/art.jpg', + spotify_url: 'https://open.spotify.com/track/123', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('track'); + expect(result.artist_name).toBe('Built to Spill'); + expect(result.album_title).toBe('Keep it Like a Secret'); + expect(result.track_title).toBe('Carry the Zero'); + expect(result.record_label).toBe('Warner Bros'); + expect(result.album_id).toBe(1); + expect(result.rotation_id).toBe(5); + expect(result.request_flag).toBe(true); + expect(result.rotation_play_freq).toBe('H'); + expect(result.artwork_url).toBe('https://example.com/art.jpg'); + expect(result.spotify_url).toBe('https://open.spotify.com/track/123'); + }); + + it('excludes message field from track entries', () => { + const entry = createBaseEntry({ + entry_type: 'track', + artist_name: 'Test Artist', + message: 'should not appear', + }); + + const result = transformToV2(entry); + + expect(result.message).toBeUndefined(); + }); + + it('includes all metadata fields for tracks', () => { + const entry = createBaseEntry({ + entry_type: 'track', + artwork_url: 'art.jpg', + discogs_url: 'discogs.com', + release_year: 1999, + spotify_url: 'spotify.com', + apple_music_url: 'apple.com', + youtube_music_url: 'youtube.com', + bandcamp_url: 'bandcamp.com', + soundcloud_url: 'soundcloud.com', + artist_bio: 'A great band', + artist_wikipedia_url: 'wiki.com', + }); + + const result = transformToV2(entry); + + expect(result.artwork_url).toBe('art.jpg'); + expect(result.discogs_url).toBe('discogs.com'); + expect(result.release_year).toBe(1999); + expect(result.spotify_url).toBe('spotify.com'); + expect(result.apple_music_url).toBe('apple.com'); + expect(result.youtube_music_url).toBe('youtube.com'); + expect(result.bandcamp_url).toBe('bandcamp.com'); + expect(result.soundcloud_url).toBe('soundcloud.com'); + expect(result.artist_bio).toBe('A great band'); + expect(result.artist_wikipedia_url).toBe('wiki.com'); + }); + }); + + describe('show_start entries', () => { + it('parses dj_name and timestamp from message', () => { + const entry = createBaseEntry({ + entry_type: 'show_start', + message: 'Start of Show: DJ Cool Cat joined the set at 1/15/2024, 7:00:00 PM', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('show_start'); + expect(result.dj_name).toBe('Cool Cat'); + expect(result.timestamp).toBe('1/15/2024, 7:00:00 PM'); + }); + + it('excludes track-specific fields from show_start entries', () => { + const entry = createBaseEntry({ + entry_type: 'show_start', + message: 'Start of Show: DJ Test joined the set at 1/15/2024, 7:00:00 PM', + artist_name: 'should not appear', + album_title: 'should not appear', + }); + + const result = transformToV2(entry); + + expect(result.artist_name).toBeUndefined(); + expect(result.album_title).toBeUndefined(); + expect(result.track_title).toBeUndefined(); + expect(result.rotation_play_freq).toBeUndefined(); + }); + + it('handles malformed show_start message gracefully', () => { + const entry = createBaseEntry({ + entry_type: 'show_start', + message: 'Some other message format', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('show_start'); + expect(result.dj_name).toBe(''); + expect(result.timestamp).toBe(''); + }); + }); + + describe('show_end entries', () => { + it('parses dj_name and timestamp from message', () => { + const entry = createBaseEntry({ + entry_type: 'show_end', + message: 'End of Show: DJ Night Owl left the set at 1/15/2024, 10:00:00 PM', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('show_end'); + expect(result.dj_name).toBe('DJ Night Owl'); + expect(result.timestamp).toBe('1/15/2024, 10:00:00 PM'); + }); + + it('excludes track-specific fields from show_end entries', () => { + const entry = createBaseEntry({ + entry_type: 'show_end', + message: 'End of Show: Test left the set at 1/15/2024, 10:00:00 PM', + }); + + const result = transformToV2(entry); + + expect(result.artist_name).toBeUndefined(); + expect(result.album_title).toBeUndefined(); + expect(result.artwork_url).toBeUndefined(); + }); + }); + + describe('dj_join entries', () => { + it('parses dj_name from message', () => { + const entry = createBaseEntry({ + entry_type: 'dj_join', + message: 'MC Hammer joined the set!', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('dj_join'); + expect(result.dj_name).toBe('MC Hammer'); + }); + + it('excludes track and message fields', () => { + const entry = createBaseEntry({ + entry_type: 'dj_join', + message: 'Test DJ joined the set!', + }); + + const result = transformToV2(entry); + + expect(result.message).toBeUndefined(); + expect(result.artist_name).toBeUndefined(); + }); + + it('handles malformed dj_join message gracefully', () => { + const entry = createBaseEntry({ + entry_type: 'dj_join', + message: 'Invalid format', + }); + + const result = transformToV2(entry); + + expect(result.dj_name).toBe(''); + }); + }); + + describe('dj_leave entries', () => { + it('parses dj_name from message', () => { + const entry = createBaseEntry({ + entry_type: 'dj_leave', + message: 'DJ Shadow left the set!', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('dj_leave'); + expect(result.dj_name).toBe('DJ Shadow'); + }); + + it('handles malformed dj_leave message gracefully', () => { + const entry = createBaseEntry({ + entry_type: 'dj_leave', + message: null, + }); + + const result = transformToV2(entry); + + expect(result.dj_name).toBe(''); + }); + }); + + describe('talkset entries', () => { + it('includes message field', () => { + const entry = createBaseEntry({ + entry_type: 'talkset', + message: 'Station ID at the top of the hour', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('talkset'); + expect(result.message).toBe('Station ID at the top of the hour'); + }); + + it('excludes track-specific fields', () => { + const entry = createBaseEntry({ + entry_type: 'talkset', + message: 'PSA announcement', + artist_name: 'should not appear', + }); + + const result = transformToV2(entry); + + expect(result.artist_name).toBeUndefined(); + expect(result.album_title).toBeUndefined(); + }); + }); + + describe('breakpoint entries', () => { + it('includes message field (can be null)', () => { + const entry = createBaseEntry({ + entry_type: 'breakpoint', + message: 'Top of the hour', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('breakpoint'); + expect(result.message).toBe('Top of the hour'); + }); + + it('handles null message for breakpoint', () => { + const entry = createBaseEntry({ + entry_type: 'breakpoint', + message: null, + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('breakpoint'); + expect(result.message).toBeNull(); + }); + }); + + describe('message entries', () => { + it('includes message field', () => { + const entry = createBaseEntry({ + entry_type: 'message', + message: 'Custom user message here', + }); + + const result = transformToV2(entry); + + expect(result.entry_type).toBe('message'); + expect(result.message).toBe('Custom user message here'); + }); + + it('excludes track-specific fields', () => { + const entry = createBaseEntry({ + entry_type: 'message', + message: 'Test message', + rotation_play_freq: 'H', + }); + + const result = transformToV2(entry); + + expect(result.rotation_play_freq).toBeUndefined(); + }); + }); + + describe('base fields', () => { + it('always includes id, show_id, play_order, add_time, entry_type', () => { + const testCases: Array = [ + 'track', + 'show_start', + 'show_end', + 'dj_join', + 'dj_leave', + 'talkset', + 'breakpoint', + 'message', + ]; + + for (const entryType of testCases) { + const entry = createBaseEntry({ + entry_type: entryType, + id: 42, + show_id: 100, + play_order: 5, + message: entryType === 'show_start' + ? 'Start of Show: DJ Test joined the set at 1/1/2024, 12:00:00 PM' + : entryType === 'show_end' + ? 'End of Show: Test left the set at 1/1/2024, 1:00:00 PM' + : entryType === 'dj_join' + ? 'Test joined the set!' + : entryType === 'dj_leave' + ? 'Test left the set!' + : 'Test message', + }); + + const result = transformToV2(entry); + + expect(result.id).toBe(42); + expect(result.show_id).toBe(100); + expect(result.play_order).toBe(5); + expect(result.add_time).toEqual(entry.add_time); + expect(result.entry_type).toBe(entryType); + } + }); + }); + + describe('edge cases', () => { + it('handles unknown entry_type by returning all fields', () => { + const entry = createBaseEntry({ + entry_type: 'unknown_type' as any, + message: 'test', + artist_name: 'test artist', + }); + + const result = transformToV2(entry); + + // Should return the entry as-is for unknown types + expect(result.message).toBe('test'); + expect(result.artist_name).toBe('test artist'); + }); + + it('handles null show_id', () => { + const entry = createBaseEntry({ + entry_type: 'track', + show_id: null, + }); + + const result = transformToV2(entry); + + expect(result.show_id).toBeNull(); + }); + + it('handles special characters in DJ names', () => { + const entry = createBaseEntry({ + entry_type: 'dj_join', + message: "DJ O'Brien & The Crew joined the set!", + }); + + const result = transformToV2(entry); + + expect(result.dj_name).toBe("DJ O'Brien & The Crew"); + }); + }); + }); +});