From d142f47464830cc97006bbdd1569c0f96f721575 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 14 Feb 2026 23:15:11 -0800 Subject: [PATCH] feat: add labels table and normalize label data Add normalized label support to Backend-Service: Schema: - Add labels table (id, label_name, parent_label_id) - Add label_id FK to library and flowsheet tables - Add label_id to library_artist_view and rotation_library_view Service layer: - Add labels service with CRUD + search + upsert by name - Add labels controller and routes (GET/POST /labels) - Update library controller to resolve label strings to label_id - Update library service to return label_id in queries - Update flowsheet service to propagate label_id through entries - Add label_id to V2 flowsheet track entries Tests: - Add labels service unit tests (8 tests) - Add flowsheet label_id propagation test - Update database mock with label support --- apps/backend/app.ts | 3 + .../controllers/flowsheet.controller.ts | 3 + apps/backend/controllers/labels.controller.ts | 79 ++++++++++++ .../backend/controllers/library.controller.ts | 10 ++ apps/backend/routes/labels.route.ts | 29 +++++ apps/backend/services/flowsheet.service.ts | 5 + apps/backend/services/labels.service.ts | 59 +++++++++ apps/backend/services/library.service.ts | 3 + shared/database/src/schema.ts | 13 ++ shared/database/src/types/flowsheet.types.ts | 1 + tests/mocks/database.mock.ts | 14 +++ tests/unit/services/flowsheet.service.test.ts | 14 +++ tests/unit/services/labels.service.test.ts | 116 ++++++++++++++++++ 13 files changed, 349 insertions(+) create mode 100644 apps/backend/controllers/labels.controller.ts create mode 100644 apps/backend/routes/labels.route.ts create mode 100644 apps/backend/services/labels.service.ts create mode 100644 tests/unit/services/labels.service.test.ts diff --git a/apps/backend/app.ts b/apps/backend/app.ts index b6b32c7d..38430d0a 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -6,6 +6,7 @@ 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 { labels_route } from './routes/labels.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'; @@ -36,6 +37,8 @@ const swaggerDoc = parse_yaml(swaggerContent); app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc)); // Business logic routes +app.use('/labels', labels_route); + app.use('/library', library_route); app.use('/flowsheet', flowsheet_route); diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index 6a5adc66..09d177ee 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 { + label_id: number | null; rotation_play_freq: string | null; metadata: IFSEntryMetadata; } @@ -125,6 +126,7 @@ export type FSEntryRequestBody = { album_id?: number; rotation_id?: number; record_label: string; + label_id?: number; request_flag?: boolean; message?: string; }; @@ -261,6 +263,7 @@ export type UpdateRequestBody = { album_title?: string; track_title?: string; record_label?: string; + label_id?: number; request_flag?: boolean; message?: string; }; diff --git a/apps/backend/controllers/labels.controller.ts b/apps/backend/controllers/labels.controller.ts new file mode 100644 index 00000000..65dc493f --- /dev/null +++ b/apps/backend/controllers/labels.controller.ts @@ -0,0 +1,79 @@ +import { Request, RequestHandler } from 'express'; +import * as labelsService from '../services/labels.service.js'; + +type CreateLabelRequest = { + label_name: string; + parent_label_id?: number; +}; + +export const getLabels: RequestHandler = async (req, res, next) => { + try { + const labels = await labelsService.getAllLabels(); + res.status(200).json(labels); + } catch (e) { + console.error('Error retrieving labels'); + console.error(e); + next(e); + } +}; + +export const getLabel: RequestHandler = async (req, res, next) => { + const id = parseInt(req.query.id); + if (isNaN(id)) { + res.status(400).json({ message: 'Missing or invalid label id' }); + } else { + try { + const label = await labelsService.getLabelById(id); + if (label) { + res.status(200).json(label); + } else { + res.status(404).json({ message: 'Label not found' }); + } + } catch (e) { + console.error('Error retrieving label'); + console.error(e); + next(e); + } + } +}; + +export const createLabel: RequestHandler = async ( + req: Request, + res, + next +) => { + const { body } = req; + if (!body.label_name) { + res.status(400).json({ message: 'Missing required parameter: label_name' }); + } else { + try { + const label = await labelsService.createLabel(body.label_name, body.parent_label_id); + res.status(200).json(label); + } catch (e) { + console.error('Error creating label'); + console.error(e); + next(e); + } + } +}; + +export const searchLabelsEndpoint: RequestHandler = async ( + req, + res, + next +) => { + const query = req.query.q; + if (!query) { + res.status(400).json({ message: 'Missing required query parameter: q' }); + } else { + try { + const limit = req.query.limit ? parseInt(req.query.limit) : undefined; + const labels = await labelsService.searchLabels(query, limit); + res.status(200).json(labels); + } catch (e) { + console.error('Error searching labels'); + console.error(e); + next(e); + } + } +}; diff --git a/apps/backend/controllers/library.controller.ts b/apps/backend/controllers/library.controller.ts index 587628b5..801ec3d2 100644 --- a/apps/backend/controllers/library.controller.ts +++ b/apps/backend/controllers/library.controller.ts @@ -10,6 +10,7 @@ import { RotationRelease, } from '@wxyc/database'; import * as libraryService from '../services/library.service.js'; +import * as labelsService from '../services/labels.service.js'; type NewAlbumRequest = { album_title: string; @@ -17,6 +18,7 @@ type NewAlbumRequest = { artist_id?: number; alternate_artist_name?: string; label: string; + label_id?: number; genre_id: number; format_id: number; disc_quantity?: number; @@ -55,12 +57,20 @@ export const addAlbum: RequestHandler = async (req: Request ({ album_title: raw.album_title, track_title: raw.track_title, record_label: raw.record_label, + label_id: raw.label_id, rotation_id: raw.rotation_id, rotation_play_freq: raw.rotation_play_freq, request_flag: raw.request_flag ?? false, @@ -506,6 +509,7 @@ export const getAlbumFromDB = async (album_id: number) => { artist_name: artists.artist_name, album_title: library.album_title, record_label: library.label, + label_id: library.label_id, }) .from(library) .innerJoin(artists, eq(artists.id, library.artist_id)) @@ -601,6 +605,7 @@ export const transformToV2 = (entry: IFSEntry): Record => { album_title: entry.album_title, track_title: entry.track_title, record_label: entry.record_label, + label_id: entry.label_id, request_flag: entry.request_flag, rotation_play_freq: entry.rotation_play_freq, artwork_url: entry.metadata?.artwork_url ?? null, diff --git a/apps/backend/services/labels.service.ts b/apps/backend/services/labels.service.ts new file mode 100644 index 00000000..416ad027 --- /dev/null +++ b/apps/backend/services/labels.service.ts @@ -0,0 +1,59 @@ +import { eq, sql } from 'drizzle-orm'; +import { db, labels, Label } from '@wxyc/database'; + +export const getAllLabels = async (): Promise => { + return await db.select().from(labels); +}; + +export const getLabelById = async (id: number): Promise