Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/backend/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ components:
type: integer
show_id:
type: integer
rotation_play_freq:
rotation_bin:
type: string
message:
type: string
Expand Down Expand Up @@ -183,7 +183,7 @@ components:
type: string
genre_name:
type: string
play_freq:
rotation_bin:
type: string
nullable: true
add_date:
Expand Down Expand Up @@ -219,7 +219,7 @@ components:
add_date:
type: string
format: date
play_freq:
rotation_bin:
type: string
enum: ['S', 'L', 'M', 'H']
kill_date:
Expand All @@ -231,11 +231,11 @@ components:

NewRotationRequest:
type: object
required: ['album_id', 'play_freq']
required: ['album_id', 'rotation_bin']
properties:
album_id:
type: integer
play_freq:
rotation_bin:
type: string
enum: ['S', 'L', 'M', 'H']
kill_date:
Expand Down
7 changes: 5 additions & 2 deletions apps/backend/controllers/flowsheet.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface IFSEntryMetadata {
}

export interface IFSEntry extends FSEntry {
rotation_play_freq: string | null;
rotation_bin: string | null;
metadata: IFSEntryMetadata;
}

Expand Down Expand Up @@ -406,9 +406,12 @@ export const changeOrder: RequestHandler<object, unknown, { entry_id: number; ne
}
};

export interface ShowInfo extends Show {
export interface ShowMetadata extends Show {
specialty_show_name: string;
show_djs: { id: string | null; dj_name: string | null }[];
}

export interface ShowInfo extends ShowMetadata {
entries: FSEntry[];
}

Expand Down
88 changes: 79 additions & 9 deletions apps/backend/controllers/flowsheet.v2.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,81 @@
import { RequestHandler } from 'express';
import * as flowsheet_service from '../services/flowsheet.service.js';

type PaginationQuery = {
page?: string;
limit?: string;
};

const MAX_ITEMS = 200;

/**
* GET /v2/flowsheet/
* Paginated flowsheet entries in V2 discriminated union format.
*/
export const getEntries: RequestHandler<object, unknown, object, PaginationQuery> = async (req, res, next) => {
const page = parseInt(req.query.page ?? '0');
const limit = parseInt(req.query.limit ?? '30');

if (isNaN(limit) || limit < 1) {
res.status(400).json({ message: 'limit must be a positive number' });
return;
}

if (limit > MAX_ITEMS) {
res.status(400).json({ message: 'Requested too many entries' });
return;
}

if (isNaN(page) || page < 0) {
res.status(400).json({ message: 'page must be a non-negative number' });
return;
}

try {
const offset = page * limit;
const [entries, total] = await Promise.all([
flowsheet_service.getEntriesByPage(offset, limit),
flowsheet_service.getEntryCount(),
]);

const totalPages = Math.ceil(total / limit);

res.status(200).json({
entries: entries.map(flowsheet_service.transformToV2),
total,
page,
limit,
totalPages,
});
} catch (e) {
console.error('Error: Failed to retrieve V2 flowsheet entries');
console.error(e);
next(e);
}
};

/**
* GET /v2/flowsheet/latest
* Single latest flowsheet entry in V2 discriminated union format.
*/
export const getLatest: RequestHandler = async (req, res, next) => {
try {
const entries = await flowsheet_service.getEntriesByPage(0, 1);
if (entries.length) {
res.status(200).json(flowsheet_service.transformToV2(entries[0]));
} else {
res.status(404).json({ message: 'No entries found' });
}
} catch (e) {
console.error('Error: Failed to retrieve latest V2 flowsheet entry');
console.error(e);
next(e);
}
};

/**
* GET /v2/flowsheet/playlist
* Get show info with entries in discriminated union format
* Show info with entries in V2 discriminated union format.
*/
export const getShowInfo: RequestHandler<object, unknown, object, { show_id: string }> = async (req, res, next) => {
const showId = parseInt(req.query.show_id);
Expand All @@ -14,16 +86,14 @@ export const getShowInfo: RequestHandler<object, unknown, object, { show_id: str
}

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<typeof flowsheet_service.transformToV2>[0])
);
const [showMetadata, ifsEntries] = await Promise.all([
flowsheet_service.getShowMetadata(showId),
flowsheet_service.getEntriesByShow(showId),
]);

res.status(200).json({
...showInfo,
entries: v2Entries,
...showMetadata,
entries: ifsEntries.map(flowsheet_service.transformToV2),
});
} catch (e) {
console.error('Error: Failed to retrieve playlist');
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/controllers/library.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ export const getRotation: RequestHandler = async (req, res, next) => {

export type RotationAddRequest = Omit<NewRotationRelease, 'id'>;
export const addRotation: RequestHandler<object, unknown, NewRotationRelease> = async (req, res, next) => {
if (req.body.album_id === undefined || req.body.play_freq === undefined) {
res.status(400).send('Missing Parameters: album_id or play_freq');
if (req.body.album_id === undefined || req.body.rotation_bin === undefined) {
res.status(400).send('Missing Parameters: album_id or rotation_bin');
} else {
try {
const rotationRelease: RotationRelease = await libraryService.addToRotation(req.body);
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/routes/flowsheet.v2.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@ import * as flowsheetV2Controller from '../controllers/flowsheet.v2.controller.j

export const flowsheet_v2_route = Router();

// V2 paginated entries in discriminated union format
flowsheet_v2_route.get('/', flowsheetV2Controller.getEntries);

// V2 latest entry in discriminated union format
flowsheet_v2_route.get('/latest', flowsheetV2Controller.getLatest);

// V2 playlist returns entries in discriminated union format
flowsheet_v2_route.get('/playlist', flowsheetV2Controller.getShowInfo);
36 changes: 27 additions & 9 deletions apps/backend/services/flowsheet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
album_metadata,
artist_metadata,
} from '@wxyc/database';
import { IFSEntry, ShowInfo, UpdateRequestBody } from '../controllers/flowsheet.controller.js';
import { IFSEntry, ShowInfo, ShowMetadata, UpdateRequestBody } from '../controllers/flowsheet.controller.js';
import { PgSelectQueryBuilder, QueryBuilder } from 'drizzle-orm/pg-core';

// Track when the flowsheet was last modified for conditional responses (304 Not Modified)
Expand Down Expand Up @@ -51,7 +51,7 @@ const FSEntryFieldsRaw = {
track_title: flowsheet.track_title,
record_label: flowsheet.record_label,
rotation_id: flowsheet.rotation_id,
rotation_play_freq: rotation.play_freq,
rotation_bin: rotation.rotation_bin,
request_flag: flowsheet.request_flag,
message: flowsheet.message,
play_order: flowsheet.play_order,
Expand Down Expand Up @@ -81,7 +81,7 @@ type FSEntryRaw = {
track_title: string | null;
record_label: string | null;
rotation_id: number | null;
rotation_play_freq: string | null;
rotation_bin: string | null;
request_flag: boolean | null;
message: string | null;
play_order: number | null;
Expand Down Expand Up @@ -109,7 +109,7 @@ const transformToIFSEntry = (raw: FSEntryRaw): IFSEntry => ({
track_title: raw.track_title,
record_label: raw.record_label,
rotation_id: raw.rotation_id,
rotation_play_freq: raw.rotation_play_freq,
rotation_bin: raw.rotation_bin,
request_flag: raw.request_flag ?? false,
message: raw.message,
play_order: raw.play_order ?? 0,
Expand All @@ -128,6 +128,14 @@ const transformToIFSEntry = (raw: FSEntryRaw): IFSEntry => ({
},
});

/** Count total flowsheet entries (for pagination) */
export const getEntryCount = async (): Promise<number> => {
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(flowsheet);
return result[0].count;
};

/** Gets flowsheet entries by page with metadata joins */
export const getEntriesByPage = async (offset: number, limit: number): Promise<IFSEntry[]> => {
const raw = await db
Expand Down Expand Up @@ -557,13 +565,12 @@ export const changeOrder = async (entry_id: number, position_new: number): Promi
return response[0];
};

export const getPlaylist = async (show_id: number): Promise<ShowInfo> => {
/** Gets show metadata (DJs, specialty show name) without fetching entries */
export const getShowMetadata = async (show_id: number): Promise<ShowMetadata> => {
const show = await db.select().from(shows).where(eq(shows.id, show_id));

const showDJs = (await getDJsInShow(show_id, false)).map((dj) => ({ id: dj.id, dj_name: dj.djName || dj.name }));

const entries = await db.select().from(flowsheet).where(eq(flowsheet.show_id, show_id));

let specialty_show_name = '';
if (show[0].specialty_id != null) {
const specialty_show = await db.select().from(specialty_shows).where(eq(specialty_shows.id, show[0].specialty_id));
Expand All @@ -574,7 +581,18 @@ export const getPlaylist = async (show_id: number): Promise<ShowInfo> => {
...show[0],
specialty_show_name: specialty_show_name,
show_djs: showDJs,
entries: entries,
};
};

export const getPlaylist = async (show_id: number): Promise<ShowInfo> => {
const [metadata, entries] = await Promise.all([
getShowMetadata(show_id),
db.select().from(flowsheet).where(eq(flowsheet.show_id, show_id)),
]);

return {
...metadata,
entries,
};
};

Expand Down Expand Up @@ -602,7 +620,7 @@ export const transformToV2 = (entry: IFSEntry): Record<string, unknown> => {
track_title: entry.track_title,
record_label: entry.record_label,
request_flag: entry.request_flag,
rotation_play_freq: entry.rotation_play_freq,
rotation_bin: entry.rotation_bin,
artwork_url: entry.metadata?.artwork_url ?? null,
discogs_url: entry.metadata?.discogs_url ?? null,
release_year: entry.metadata?.release_year ?? null,
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/services/library.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export interface Rotation {
rotation_id: number;
add_date: Date;
rotation_add_date: string;
play_freq: 'S' | 'L' | 'M' | 'H';
rotation_bin: 'S' | 'L' | 'M' | 'H';
rotation_kill_date: string | null;
plays: number;
}
Expand All @@ -64,7 +64,7 @@ export const getRotationFromDB = async (): Promise<Rotation[]> => {
rotation_id: rotation.id,
add_date: library.add_date,
rotation_add_date: rotation.add_date,
play_freq: rotation.play_freq,
rotation_bin: rotation.rotation_bin,
rotation_kill_date: rotation.kill_date,
plays: library.plays,
})
Expand Down
4 changes: 2 additions & 2 deletions dev_env/seed_db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ INSERT INTO wxyc_schema.library(
artist_id, genre_id, format_id, album_title, code_number)
VALUES (6, 1, 2, 'Homogenic', 1);

INSERT INTO wxyc_schema.rotation(album_id, play_freq) VALUES (1, 'L');
INSERT INTO wxyc_schema.rotation(album_id, rotation_bin) VALUES (1, 'L');

-- Add album 4 to rotation for metadata.spec.js rotation tests
INSERT INTO wxyc_schema.rotation(album_id, play_freq) VALUES (4, 'M');
INSERT INTO wxyc_schema.rotation(album_id, rotation_bin) VALUES (4, 'M');
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- Rename play_freq column to rotation_bin on the rotation table.
-- The enum type (freq_enum) stays the same; only the column name changes.

-- Drop views that reference the column
DROP VIEW IF EXISTS "wxyc_schema"."library_artist_view";
--> statement-breakpoint
DROP VIEW IF EXISTS "wxyc_schema"."rotation_library_view";
--> statement-breakpoint

-- Rename the column
ALTER TABLE "wxyc_schema"."rotation" RENAME COLUMN "play_freq" TO "rotation_bin";
--> statement-breakpoint

-- Recreate library_artist_view with the new column name
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);
--> statement-breakpoint

-- Recreate rotation_library_view with the new column name
CREATE VIEW "wxyc_schema"."rotation_library_view" AS
SELECT "wxyc_schema"."library"."id",
"wxyc_schema"."rotation"."id" AS "rotation_id",
"wxyc_schema"."library"."label",
"wxyc_schema"."rotation"."rotation_bin",
"wxyc_schema"."library"."album_title",
"wxyc_schema"."artists"."artist_name",
"wxyc_schema"."rotation"."kill_date"
FROM "wxyc_schema"."library"
INNER JOIN "wxyc_schema"."rotation" ON "wxyc_schema"."library"."id" = "wxyc_schema"."rotation"."album_id"
INNER JOIN "wxyc_schema"."artists" ON "wxyc_schema"."artists"."id" = "wxyc_schema"."library"."artist_id";
Loading