From b4dc3c106678ed7689f9bd4f98ca92b7901946c1 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 13:28:49 -0800 Subject: [PATCH 1/8] feat: add typecheck scripts and fix type errors Add `tsc --noEmit` typecheck scripts to all workspace package.json files and a root orchestrator that builds shared packages first for correct module resolution. Type error fixes: - Remove deprecated bearerClient import (better-auth 1.4.x) - Remove IFSEntry entry_type override that conflicted with FSEntry enum - Cast session.user for admin plugin ban fields - Fix LRUCache generics to satisfy `V extends {}` constraint - Use nullish coalescing for nullable-to-non-nullable transforms - Access metadata fields through entry.metadata in V2 transform - Replace db.execute().rows with direct cast (drizzle-orm 0.41) - Replace internal database path import with @wxyc/database - Cast DiscogsService for pipeline interface compatibility - Add missing project reference in authentication tsconfig - Generalize .gitignore tsbuildinfo pattern --- .gitignore | 2 +- apps/auth/package.json | 3 +- .../controllers/flowsheet.controller.ts | 1 - apps/backend/middleware/anonymousAuth.ts | 7 +++-- apps/backend/package.json | 3 +- apps/backend/services/discogs/cache.ts | 26 ++++++++-------- apps/backend/services/flowsheet.service.ts | 30 +++++++++---------- apps/backend/services/library.service.ts | 14 ++++----- .../services/metadata/metadata.cache.ts | 2 +- .../requestLine.enhanced.service.ts | 4 +-- package.json | 1 + shared/authentication/package.json | 3 +- shared/authentication/src/auth.client.ts | 2 -- shared/authentication/tsconfig.json | 1 + shared/database/package.json | 3 +- 15 files changed, 53 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 29c771a..35746e5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ dist mirror-logs .env .DS_STORE -shared/authentication/tsconfig.tsbuildinfo +*.tsbuildinfo diff --git a/apps/auth/package.json b/apps/auth/package.json index 18709ed..4841dd7 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -10,7 +10,8 @@ "build": "tsup --minify", "clean": "rm -rf dist", "docker:build": "docker build -t wxyc_auth_service:ci -f ../../Dockerfile.auth ../../", - "dev": "tsup --watch" + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" }, "author": "Jackson Meade", "license": "MIT", diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index d73b6cf..5c1c8bc 100644 --- a/apps/backend/controllers/flowsheet.controller.ts +++ b/apps/backend/controllers/flowsheet.controller.ts @@ -28,7 +28,6 @@ export interface IFSEntryMetadata { } export interface IFSEntry extends FSEntry { - entry_type: string; rotation_play_freq: string | null; metadata: IFSEntryMetadata; } diff --git a/apps/backend/middleware/anonymousAuth.ts b/apps/backend/middleware/anonymousAuth.ts index e98f9a2..db2644d 100644 --- a/apps/backend/middleware/anonymousAuth.ts +++ b/apps/backend/middleware/anonymousAuth.ts @@ -43,11 +43,12 @@ export const requireAnonymousAuth: RequestHandler = async ( return; } - // Check if user is banned (better-auth admin plugin) - if (session.user.banned) { + // Check if user is banned (better-auth admin plugin adds these fields) + const userWithBan = session.user as typeof session.user & { banned?: boolean; banReason?: string | null }; + if (userWithBan.banned) { res.status(403).json({ message: 'Access denied', - reason: session.user.banReason || 'Account suspended', + reason: userWithBan.banReason || 'Account suspended', }); return; } diff --git a/apps/backend/package.json b/apps/backend/package.json index fe111c2..67e2d14 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,7 +9,8 @@ "build": "tsup --minify", "clean": "rm -rf dist", "docker:build": "docker build -t wxyc_backend_service:ci -f ../../Dockerfile.backend ../../", - "dev": "tsup --watch" + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" }, "author": "AyBruno", "license": "MIT", diff --git a/apps/backend/services/discogs/cache.ts b/apps/backend/services/discogs/cache.ts index 1b95257..71c9472 100644 --- a/apps/backend/services/discogs/cache.ts +++ b/apps/backend/services/discogs/cache.ts @@ -26,17 +26,17 @@ export function makeCacheKey(funcName: string, args: unknown[]): string { /** * Cache instances for different types of Discogs requests. */ -let trackCache: LRUCache | null = null; -let releaseCache: LRUCache | null = null; -let searchCache: LRUCache | null = null; +let trackCache: LRUCache | null = null; +let releaseCache: LRUCache | null = null; +let searchCache: LRUCache | null = null; /** * Get or create the track search cache. */ -export function getTrackCache(): LRUCache { +export function getTrackCache(): LRUCache { if (!trackCache) { const config = getConfig(); - trackCache = new LRUCache({ + trackCache = new LRUCache({ max: config.discogsCacheMaxSize, ttl: config.discogsCacheTtlTrack * 1000, // Convert seconds to ms }); @@ -47,11 +47,11 @@ export function getTrackCache(): LRUCache { /** * Get or create the release metadata cache. */ -export function getReleaseCache(): LRUCache { +export function getReleaseCache(): LRUCache { if (!releaseCache) { const config = getConfig(); // Release cache uses half the maxsize since entries are larger - releaseCache = new LRUCache({ + releaseCache = new LRUCache({ max: Math.floor(config.discogsCacheMaxSize / 2), ttl: config.discogsCacheTtlRelease * 1000, }); @@ -62,10 +62,10 @@ export function getReleaseCache(): LRUCache { /** * Get or create the general search cache. */ -export function getSearchCache(): LRUCache { +export function getSearchCache(): LRUCache { if (!searchCache) { const config = getConfig(); - searchCache = new LRUCache({ + searchCache = new LRUCache({ max: config.discogsCacheMaxSize, ttl: config.discogsCacheTtlSearch * 1000, }); @@ -99,7 +99,7 @@ export function resetAllCaches(): void { * @param fn - Async function to cache */ export function cached( - cache: LRUCache, + cache: LRUCache, funcName: string, fn: (...args: unknown[]) => Promise ): (...args: unknown[]) => Promise { @@ -112,9 +112,9 @@ export function cached( console.log(`[Discogs Cache] Hit for ${funcName}`); // Add cached flag if result is an object if (typeof cached === 'object' && cached !== null) { - return { ...cached, cached: true } as T & { cached: boolean }; + return { ...(cached as object), cached: true } as T & { cached: boolean }; } - return cached as T; + return cached as T & { cached?: boolean }; } // Cache miss - call function @@ -123,7 +123,7 @@ export function cached( // Don't cache null/undefined results if (result !== null && result !== undefined) { - cache.set(key, result); + cache.set(key, result as object); } return result as T & { cached?: boolean }; diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index 2b35929..0dd673d 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -103,17 +103,17 @@ const transformToIFSEntry = (raw: FSEntryRaw): IFSEntry => ({ id: raw.id, show_id: raw.show_id, album_id: raw.album_id, - entry_type: raw.entry_type, + entry_type: raw.entry_type as FSEntry['entry_type'], artist_name: raw.artist_name, album_title: raw.album_title, track_title: raw.track_title, record_label: raw.record_label, rotation_id: raw.rotation_id, rotation_play_freq: raw.rotation_play_freq, - request_flag: raw.request_flag, + request_flag: raw.request_flag ?? false, message: raw.message, - play_order: raw.play_order, - add_time: raw.add_time, + play_order: raw.play_order ?? 0, + add_time: raw.add_time ?? new Date(), metadata: { artwork_url: raw.artwork_url, discogs_url: raw.discogs_url, @@ -600,16 +600,16 @@ export const transformToV2 = (entry: IFSEntry): Record => { 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, + artwork_url: entry.metadata.artwork_url, + discogs_url: entry.metadata.discogs_url, + release_year: entry.metadata.release_year, + spotify_url: entry.metadata.spotify_url, + apple_music_url: entry.metadata.apple_music_url, + youtube_music_url: entry.metadata.youtube_music_url, + bandcamp_url: entry.metadata.bandcamp_url, + soundcloud_url: entry.metadata.soundcloud_url, + artist_bio: entry.metadata.artist_bio, + artist_wikipedia_url: entry.metadata.artist_wikipedia_url, }; case 'show_start': @@ -682,6 +682,6 @@ export const transformToV2 = (entry: IFSEntry): Record => { default: // Fallback for unknown types - return all fields - return entry as Record; + return { ...baseFields, ...entry.metadata } as Record; } }; diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index a62615b..d9c0a84 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -283,7 +283,7 @@ export async function searchLibrary( `; const response = await db.execute(searchQuery); - results = response.rows as LibraryArtistViewEntry[]; + results = response as unknown as LibraryArtistViewEntry[]; // If no results with trigram, try LIKE fallback with significant words if (results.length === 0) { @@ -301,13 +301,13 @@ export async function searchLibrary( `); const fallbackResponse = await db.execute(fallbackQuery); - results = fallbackResponse.rows as LibraryArtistViewEntry[]; + results = fallbackResponse as unknown as LibraryArtistViewEntry[]; } } } else if (artist || title) { // Filtered search by artist and/or title const response = await fuzzySearchLibrary(artist, title, limit); - results = response.rows as LibraryArtistViewEntry[]; + results = response as unknown as LibraryArtistViewEntry[]; } return results.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); @@ -337,7 +337,7 @@ export async function findSimilarArtist( `; const response = await db.execute(query); - const rows = response.rows as Array<{ artist_name: string; sim: number }>; + const rows = response as unknown as Array<{ artist_name: string; sim: number }>; if (rows.length > 0) { const match = rows[0]; @@ -376,7 +376,7 @@ export async function searchAlbumsByTitle( `; const response = await db.execute(query); - const rows = response.rows as LibraryArtistViewEntry[]; + const rows = response as unknown as LibraryArtistViewEntry[]; // If no trigram matches, try keyword search if (rows.length === 0) { @@ -394,7 +394,7 @@ export async function searchAlbumsByTitle( `); const fallbackResponse = await db.execute(fallbackQuery); - return (fallbackResponse.rows as LibraryArtistViewEntry[]).map((row) => + return (fallbackResponse as unknown as LibraryArtistViewEntry[]).map((row) => enrichLibraryResult(viewRowToLibraryResult(row)) ); } @@ -424,7 +424,7 @@ export async function searchByArtist( `; const response = await db.execute(query); - const rows = response.rows as LibraryArtistViewEntry[]; + const rows = response as unknown as LibraryArtistViewEntry[]; return rows.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); } diff --git a/apps/backend/services/metadata/metadata.cache.ts b/apps/backend/services/metadata/metadata.cache.ts index eff9c4e..fc28e30 100644 --- a/apps/backend/services/metadata/metadata.cache.ts +++ b/apps/backend/services/metadata/metadata.cache.ts @@ -1,7 +1,7 @@ /** * Metadata storage - simple database operations for metadata persistence */ -import { db } from '../../../../shared/database/src/client.js'; +import { db } from '@wxyc/database'; import { album_metadata, artist_metadata, diff --git a/apps/backend/services/requestLine/requestLine.enhanced.service.ts b/apps/backend/services/requestLine/requestLine.enhanced.service.ts index 4fa0cd3..2a1fa9b 100644 --- a/apps/backend/services/requestLine/requestLine.enhanced.service.ts +++ b/apps/backend/services/requestLine/requestLine.enhanced.service.ts @@ -19,7 +19,7 @@ import { } from './types.js'; import { getConfig, isParsingEnabled, isDiscogsEnabled } from './config.js'; import { parseRequest, isParserAvailable } from '../ai/index.js'; -import { executeSearchPipeline, getSearchTypeFromState } from './search/index.js'; +import { executeSearchPipeline, getSearchTypeFromState, type PipelineOptions } from './search/index.js'; import { findSimilarArtist } from '../library.service.js'; import { DiscogsService, isDiscogsAvailable } from '../discogs/index.js'; import { fetchArtworkForItems } from '../artwork/index.js'; @@ -231,7 +231,7 @@ export async function processRequest( // Step 3: Execute search strategy pipeline if (config.enableLibrarySearch) { const searchState = await executeSearchPipeline(parsed, message, { - discogsService: isDiscogsAvailable() ? DiscogsService : undefined, + discogsService: isDiscogsAvailable() ? DiscogsService as unknown as PipelineOptions['discogsService'] : undefined, albumsForSearch, }); diff --git a/package.json b/package.json index 18b1fcf..5b20c83 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "All necessary app services for the WXYC flowsheet backend", "scripts": { + "typecheck": "npm run build --workspace=@wxyc/database --workspace=@wxyc/authentication && npm run typecheck --workspace=@wxyc/database --workspace=shared/** --workspace=apps/**", "lint:env": "node scripts/lint-env.js", "build": "npm run build --workspace=@wxyc/database --workspace=shared/** --workspace=apps/**", "dev": "dotenvx run -f .env -- concurrently \"npm:dev:auth\" \"npm:dev:backend\"", diff --git a/shared/authentication/package.json b/shared/authentication/package.json index bb4c7a9..55caad5 100644 --- a/shared/authentication/package.json +++ b/shared/authentication/package.json @@ -15,7 +15,8 @@ }, "scripts": { "build": "tsup", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" }, "dependencies": { "@aws-sdk/client-ses": "^3.971.0", diff --git a/shared/authentication/src/auth.client.ts b/shared/authentication/src/auth.client.ts index c781093..03ae64b 100644 --- a/shared/authentication/src/auth.client.ts +++ b/shared/authentication/src/auth.client.ts @@ -2,7 +2,6 @@ import { createAuthClient } from 'better-auth/client'; import { adminClient, anonymousClient, - bearerClient, jwtClient, usernameClient, organizationClient, @@ -16,7 +15,6 @@ export const authClient = createAuthClient({ adminClient(), usernameClient(), anonymousClient(), - bearerClient(), jwtClient(), organizationClient(), ], diff --git a/shared/authentication/tsconfig.json b/shared/authentication/tsconfig.json index 96e9b81..c1048d7 100644 --- a/shared/authentication/tsconfig.json +++ b/shared/authentication/tsconfig.json @@ -1,5 +1,6 @@ { "extends": ["../../tsconfig.base.json"], + "references": [{ "path": "../database" }], "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"], "compilerOptions": { diff --git a/shared/database/package.json b/shared/database/package.json index cfcef11..35c48b8 100644 --- a/shared/database/package.json +++ b/shared/database/package.json @@ -15,7 +15,8 @@ }, "scripts": { "build": "tsup", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" }, "dependencies": { "drizzle-orm": "^0.41.0", From 915f87a1b0ad3076b6e94366194dacb2498c0922 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 14:05:39 -0800 Subject: [PATCH 2/8] feat: add ESLint with TypeScript type-checked rules and security plugin Configure ESLint 9 flat config with typescript-eslint recommendedTypeChecked, eslint-plugin-security, and eslint-config-prettier. Fix all lint errors (async safety, useless escapes) while keeping warnings for gradual cleanup. --- apps/auth/app.ts | 4 +- apps/backend/controllers/djs.controller.ts | 2 +- .../controllers/schedule.controller.ts | 2 +- .../middleware/legacy/commandqueue.mirror.ts | 2 +- .../middleware/legacy/flowsheet.mirror.ts | 6 +- .../middleware/legacy/mirror.middleware.ts | 53 +- apps/backend/middleware/rateLimiting.ts | 4 +- apps/backend/services/discogs/cache.ts | 2 +- .../metadata/providers/discogs.provider.ts | 4 +- eslint.config.mjs | 98 ++ package-lock.json | 1169 ++++++++++++++++- package.json | 7 + 12 files changed, 1289 insertions(+), 64 deletions(-) create mode 100644 eslint.config.mjs diff --git a/apps/auth/app.ts b/apps/auth/app.ts index 39674b0..642f5d6 100644 --- a/apps/auth/app.ts +++ b/apps/auth/app.ts @@ -300,11 +300,11 @@ const syncAdminRoles = async () => { }; // Initialize default user and sync admin roles before starting the server -(async () => { +void (async () => { await createDefaultUser(); await syncAdminRoles(); - app.listen(parseInt(port), async () => { + app.listen(parseInt(port), () => { console.log(`listening on port: ${port}! (auth service)`); }); })(); diff --git a/apps/backend/controllers/djs.controller.ts b/apps/backend/controllers/djs.controller.ts index 1b79428..8e8cae7 100644 --- a/apps/backend/controllers/djs.controller.ts +++ b/apps/backend/controllers/djs.controller.ts @@ -15,7 +15,7 @@ export const addToBin: RequestHandler = async (req, re res.status(400).send('Bad Request, Missing DJ or album identifier: album_id'); } else { const bin_entry: NewBinEntry = { - dj_id: req.body.dj_id as string, + dj_id: req.body.dj_id, album_id: req.body.album_id, track_title: req.body.track_title === undefined ? null : req.body.track_title, }; diff --git a/apps/backend/controllers/schedule.controller.ts b/apps/backend/controllers/schedule.controller.ts index ae0a474..f456b87 100644 --- a/apps/backend/controllers/schedule.controller.ts +++ b/apps/backend/controllers/schedule.controller.ts @@ -16,7 +16,7 @@ export const getSchedule: RequestHandler = asyn export const addToSchedule: RequestHandler = async (req: Request, res, next) => { const { body } = req; try { - let response = await ScheduleService.addToSchedule(body); + const response = await ScheduleService.addToSchedule(body); res.status(200).json(response); } catch (e) { console.error('Error adding to schedule'); diff --git a/apps/backend/middleware/legacy/commandqueue.mirror.ts b/apps/backend/middleware/legacy/commandqueue.mirror.ts index e101844..29c0eb8 100644 --- a/apps/backend/middleware/legacy/commandqueue.mirror.ts +++ b/apps/backend/middleware/legacy/commandqueue.mirror.ts @@ -69,7 +69,7 @@ export class MirrorCommandQueue extends EventEmitter { } private static createTrigger = - (eventType: keyof typeof MirrorEvents) => async (cmd: MirrorCommand) => { + (eventType: keyof typeof MirrorEvents) => (cmd: MirrorCommand) => { const data: EventData = { type: eventType, payload: cmd, diff --git a/apps/backend/middleware/legacy/flowsheet.mirror.ts b/apps/backend/middleware/legacy/flowsheet.mirror.ts index 4111c7d..abcf3ad 100644 --- a/apps/backend/middleware/legacy/flowsheet.mirror.ts +++ b/apps/backend/middleware/legacy/flowsheet.mirror.ts @@ -69,7 +69,7 @@ const startShow = createBackendMirrorMiddleware(async (req, show) => { @NEW_RS_ID);` // SHOW_ID (same as ID) ); - var announcementEntry = await db + const announcementEntry = await db .select() .from(flowsheet) .where(eq(flowsheet.show_id, show.id)) @@ -100,7 +100,7 @@ export const endShow = createBackendMirrorMiddleware( LIMIT 1;` ); - var announcementEntry = await db + const announcementEntry = await db .select() .from(flowsheet) .where(eq(flowsheet.show_id, show.id)) @@ -162,7 +162,7 @@ const getAddEntrySQL = async (req: Request, entry: FSEntry) => { let message = entry.message?.trim() ?? ''; let entryTypeCode = 7; // Default to talkset - let nowPlayingFlag = 0; + const nowPlayingFlag = 0; let startTime = 0; // Map entry_type to legacy type codes diff --git a/apps/backend/middleware/legacy/mirror.middleware.ts b/apps/backend/middleware/legacy/mirror.middleware.ts index 3935e2c..9343484 100644 --- a/apps/backend/middleware/legacy/mirror.middleware.ts +++ b/apps/backend/middleware/legacy/mirror.middleware.ts @@ -9,39 +9,40 @@ export const createBackendMirrorMiddleware = tapJsonResponse(res); // After the response is sent, decide whether to enqueue work - res.once("finish", async () => { - try { - - const postHogClient = new PostHog(process.env.POSTHOG_API_KEY ?? "", { - host: "https://us.i.posthog.com", - }); + res.once("finish", () => { + void (async () => { + try { + const postHogClient = new PostHog(process.env.POSTHOG_API_KEY ?? "", { + host: "https://us.i.posthog.com", + }); - console.log("Response finished, checking for mirror work..."); - const ok = res.statusCode >= 200 && res.statusCode < 305; - const data = (res.locals as any).mirrorData as T | undefined; + console.log("Response finished, checking for mirror work..."); + const ok = res.statusCode >= 200 && res.statusCode < 305; + const data = (res.locals as any).mirrorData as T | undefined; - console.log("Response status:", res.statusCode, "ok?", ok); + console.log("Response status:", res.statusCode, "ok?", ok); - const distinctId = (req as any).user?.id ?? req.ip ?? "anonymous"; - var mirrorOn = await postHogClient.isFeatureEnabled('backend-mirror', distinctId); - mirrorOn ??= false; + const distinctId = (req as any).user?.id ?? req.ip ?? "anonymous"; + let mirrorOn = await postHogClient.isFeatureEnabled('backend-mirror', distinctId); + mirrorOn ??= false; - if ( - !ok || - data == null || - !mirrorOn - ) - return; + if ( + !ok || + data == null || + !mirrorOn + ) + return; - console.log("Enqueuing mirror work..."); + console.log("Enqueuing mirror work..."); - const queue = MirrorCommandQueue.instance(); - queue.enqueue(await createCommand(req, data)); + const queue = MirrorCommandQueue.instance(); + queue.enqueue(await createCommand(req, data)); - await postHogClient.shutdown(); - } catch (e) { - console.error("Error in mirror middleware:", e); - } + await postHogClient.shutdown(); + } catch (e) { + console.error("Error in mirror middleware:", e); + } + })(); }); next(); diff --git a/apps/backend/middleware/rateLimiting.ts b/apps/backend/middleware/rateLimiting.ts index c407a0b..c32788b 100644 --- a/apps/backend/middleware/rateLimiting.ts +++ b/apps/backend/middleware/rateLimiting.ts @@ -21,8 +21,8 @@ const songRequestStore = new MemoryStore(); */ export const resetRateLimitStores = (): void => { if (isTestEnv) { - registrationStore.resetAll(); - songRequestStore.resetAll(); + void registrationStore.resetAll(); + void songRequestStore.resetAll(); } }; diff --git a/apps/backend/services/discogs/cache.ts b/apps/backend/services/discogs/cache.ts index 71c9472..c4697ad 100644 --- a/apps/backend/services/discogs/cache.ts +++ b/apps/backend/services/discogs/cache.ts @@ -112,7 +112,7 @@ export function cached( console.log(`[Discogs Cache] Hit for ${funcName}`); // Add cached flag if result is an object if (typeof cached === 'object' && cached !== null) { - return { ...(cached as object), cached: true } as T & { cached: boolean }; + return { ...(cached), cached: true } as T & { cached: boolean }; } return cached as T & { cached?: boolean }; } diff --git a/apps/backend/services/metadata/providers/discogs.provider.ts b/apps/backend/services/metadata/providers/discogs.provider.ts index 257aca1..da15642 100644 --- a/apps/backend/services/metadata/providers/discogs.provider.ts +++ b/apps/backend/services/metadata/providers/discogs.provider.ts @@ -302,7 +302,7 @@ export class DiscogsProvider { bio = bio.replace(/\[l=([^\]]+)\]/g, '$1'); bio = bio.replace(/\[r=([^\]]+)\]/g, '$1'); bio = bio.replace(/\[m=([^\]]+)\]/g, '$1'); - bio = bio.replace(/\[url=([^\]]+)\]([^\[]*)\[\/url\]/g, '$2'); + bio = bio.replace(/\[url=([^\]]+)\]([^[]*)\[\/url\]/g, '$2'); } return { @@ -341,7 +341,7 @@ export class DiscogsProvider { bio = bio.replace(/\[l=([^\]]+)\]/g, '$1'); bio = bio.replace(/\[r=([^\]]+)\]/g, '$1'); bio = bio.replace(/\[m=([^\]]+)\]/g, '$1'); - bio = bio.replace(/\[url=([^\]]+)\]([^\[]*)\[\/url\]/g, '$2'); + bio = bio.replace(/\[url=([^\]]+)\]([^[]*)\[\/url\]/g, '$2'); } return { diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..8ebbdb6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,98 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import security from 'eslint-plugin-security'; +import prettierConfig from 'eslint-config-prettier'; + +export default tseslint.config( + // Global ignores + { + ignores: [ + '**/dist/', + '**/node_modules/', + 'coverage/', + '**/*.js', + '**/*.mjs', + '**/*.d.ts', + '**/*.d.mts', + 'shared/database/src/migrations/**', + 'dev_env/**', + 'scripts/**', + 'drizzle.config.ts', + 'jest.unit.config.ts', + 'jest.config.json', + 'jest.parallel.config.json', + '**/tsup.config.ts', + ], + }, + + // Base recommended rules + eslint.configs.recommended, + + // TypeScript type-checked rules + ...tseslint.configs.recommendedTypeChecked, + + // Security rules + security.configs.recommended, + + // Prettier compat (disables formatting rules) + prettierConfig, + + // TypeScript project config + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + + // App and shared code rules + { + files: ['apps/**/*.ts', 'shared/**/*.ts'], + rules: { + // Async safety (critical for Express handlers) + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + + // Type safety (warn tier for gradual cleanup) + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + + // Downgrade to warn for existing code patterns + '@typescript-eslint/require-await': 'warn', + '@typescript-eslint/restrict-template-expressions': 'warn', + '@typescript-eslint/no-base-to-string': 'warn', + '@typescript-eslint/no-namespace': 'warn', + '@typescript-eslint/no-unnecessary-type-assertion': 'warn', + '@typescript-eslint/unbound-method': 'warn', + + // Unused vars (respect _prefix convention) + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + + // Security (disable noisy rules) + 'security/detect-object-injection': 'off', + + // Server-side code + 'no-console': 'off', + }, + }, + + // Relaxed rules for tests + { + files: ['tests/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + }, + } +); diff --git a/package-lock.json b/package-lock.json index 855aabc..9ef6de8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@dotenvx/dotenvx": "^1.51.0", + "@eslint/js": "^9.39.2", "@types/jest": "^29.5.14", "@types/node": "^24.1.0", "@types/pg": "^8.11.10", @@ -25,6 +26,9 @@ "concurrently": "^9.1.2", "drizzle-kit": "^0.31.5", "drizzle-orm": "^0.41.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-security": "^3.0.1", "jest": "^30.0.5", "jest-html-reporters": "^3.1.7", "nodemon": "^3.1.7", @@ -36,6 +40,7 @@ "tsup": "^8.4.0", "tsx": "^4.21.0", "typescript": "^5.6.2", + "typescript-eslint": "^8.55.0", "wait-on": "^8.0.1" } }, @@ -2218,6 +2223,218 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -2272,6 +2489,58 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4137,6 +4406,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", @@ -4265,6 +4541,249 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -4620,6 +5139,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -4645,6 +5174,23 @@ "node": ">= 8.0.0" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -5750,6 +6296,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -6162,43 +6715,282 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "devOptional": true, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-3.0.1.tgz", + "integrity": "sha512-XjVGBhtDZJfyuhIxnQ/WMm385RbX3DBu7H1J7HNNhmB2tnGxMeqVSnYv79oAj992ayvIBZghsymwkYFS6cGH4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.4" + "p-locate": "^5.0.0" }, - "peerDependencies": { - "esbuild": ">=0.12 <1" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=6" + "node": "*" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esprima": { @@ -6215,6 +7007,52 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -6371,6 +7209,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6378,6 +7223,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -6454,6 +7306,19 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6514,6 +7379,27 @@ "rollup": "^4.34.8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -6829,6 +7715,19 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7049,6 +7948,33 @@ "dev": true, "license": "ISC" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -8039,6 +8965,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -8046,6 +8979,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8072,6 +9019,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kysely": { "version": "0.28.9", "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.9.tgz", @@ -8091,6 +9048,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -8148,6 +9119,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8742,6 +9720,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8804,6 +9800,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -9102,6 +10111,16 @@ "node": ">=20" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", @@ -9172,6 +10191,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -9261,6 +10290,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9378,6 +10417,16 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10191,6 +11240,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -11414,6 +12476,19 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -11490,6 +12565,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", @@ -11609,6 +12708,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -11711,6 +12820,16 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index 5b20c83..2916896 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "All necessary app services for the WXYC flowsheet backend", "scripts": { "typecheck": "npm run build --workspace=@wxyc/database --workspace=@wxyc/authentication && npm run typecheck --workspace=@wxyc/database --workspace=shared/** --workspace=apps/**", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "lint:env": "node scripts/lint-env.js", "build": "npm run build --workspace=@wxyc/database --workspace=shared/** --workspace=apps/**", "dev": "dotenvx run -f .env -- concurrently \"npm:dev:auth\" \"npm:dev:backend\"", @@ -44,6 +46,7 @@ ], "devDependencies": { "@dotenvx/dotenvx": "^1.51.0", + "@eslint/js": "^9.39.2", "@types/jest": "^29.5.14", "@types/node": "^24.1.0", "@types/pg": "^8.11.10", @@ -52,6 +55,9 @@ "concurrently": "^9.1.2", "drizzle-kit": "^0.31.5", "drizzle-orm": "^0.41.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-security": "^3.0.1", "jest": "^30.0.5", "jest-html-reporters": "^3.1.7", "nodemon": "^3.1.7", @@ -63,6 +69,7 @@ "tsup": "^8.4.0", "tsx": "^4.21.0", "typescript": "^5.6.2", + "typescript-eslint": "^8.55.0", "wait-on": "^8.0.1" }, "dependencies": { From 381eff2beb4e9466735039190085cf4da5acec4e Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 14:26:20 -0800 Subject: [PATCH 3/8] chore: add Prettier configuration Add .prettierrc.json and .prettierignore with format and format:check scripts. Configured to match existing code style (single quotes, trailing commas, 120 char line width). --- .prettierignore | 5 +++++ .prettierrc.json | 6 ++++++ package.json | 2 ++ 3 files changed, 13 insertions(+) create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9b85730 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +coverage +node_modules +shared/database/src/migrations +*.sql diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..c523e2f --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 120, + "tabWidth": 2 +} diff --git a/package.json b/package.json index 2916896..687e3c3 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "lint:env": "node scripts/lint-env.js", + "format": "prettier --write .", + "format:check": "prettier --check .", "build": "npm run build --workspace=@wxyc/database --workspace=shared/** --workspace=apps/**", "dev": "dotenvx run -f .env -- concurrently \"npm:dev:auth\" \"npm:dev:backend\"", "dev:backend": "npm run dev --workspace=@wxyc/backend", From 774bc77bdd632ba6292cdd004cb86da1a55c9cc0 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 14:26:37 -0800 Subject: [PATCH 4/8] style: format codebase with Prettier Run prettier --write . to normalize all existing code formatting. No logic changes, only whitespace and quote style adjustments. --- .github/workflows/test.yml | 4 +- apps/auth/app.ts | 31 +-- apps/auth/tsconfig.json | 20 +- apps/auth/tsup.config.ts | 27 +-- apps/backend/controllers/djs.controller.ts | 2 +- apps/backend/controllers/events.conroller.ts | 35 +--- .../controllers/flowsheet.controller.ts | 2 +- .../backend/controllers/library.controller.ts | 2 +- .../controllers/schedule.controller.ts | 2 +- apps/backend/middleware/conditionalGet.ts | 6 +- .../middleware/legacy/commandqueue.mirror.ts | 108 +++++------ .../middleware/legacy/flowsheet.mirror.ts | 179 +++++++++--------- .../middleware/legacy/mirror.middleware.ts | 37 ++-- apps/backend/middleware/legacy/sql.mirror.ts | 13 +- .../middleware/legacy/utilities.mirror.ts | 23 +-- apps/backend/routes/djs.route.ts | 30 +-- apps/backend/routes/events.route.ts | 24 +-- apps/backend/routes/flowsheet.route.ts | 51 +++-- apps/backend/routes/flowsheet.v2.route.ts | 6 +- apps/backend/routes/library.route.ts | 82 ++------ apps/backend/routes/schedule.route.ts | 8 +- .../services/activityTracking.service.ts | 6 +- .../services/anonymousDevice.service.ts | 6 +- apps/backend/services/artwork/finder.ts | 11 +- apps/backend/services/artwork/index.ts | 7 +- .../services/artwork/providers/discogs.ts | 29 +-- apps/backend/services/discogs/cache.ts | 2 +- .../services/discogs/discogs.service.ts | 43 +---- apps/backend/services/discogs/index.ts | 8 +- apps/backend/services/djs.service.ts | 2 +- apps/backend/services/flowsheet.service.ts | 11 +- apps/backend/services/library.service.ts | 25 +-- .../services/metadata/metadata.cache.ts | 39 +--- .../services/metadata/metadata.service.ts | 18 +- .../metadata/providers/apple.provider.ts | 14 +- .../metadata/providers/discogs.provider.ts | 18 +- .../providers/search-urls.provider.ts | 6 +- .../metadata/providers/spotify.provider.ts | 6 +- apps/backend/services/requestLine/config.ts | 4 +- .../requestLine/matching/compilation.ts | 8 +- .../requestLine.enhanced.service.ts | 47 ++--- .../services/requestLine/search/pipeline.ts | 5 +- .../search/strategies/artistPlusAlbum.ts | 6 +- .../search/strategies/songAsArtist.ts | 24 +-- .../strategies/swappedInterpretation.ts | 4 +- .../search/strategies/trackOnCompilation.ts | 38 +--- apps/backend/services/schedule.service.ts | 2 +- apps/backend/services/slack/builder.ts | 14 +- apps/backend/services/slack/index.ts | 13 +- apps/backend/services/slack/slack.service.ts | 10 +- apps/backend/tsconfig.json | 20 +- apps/backend/tsup.config.ts | 2 +- apps/backend/utils/serverEvents.ts | 2 +- dev_env/setup-e2e-test-users.ts | 9 +- docs/metadata-service/README.md | 132 +++++++------ jest.unit.config.ts | 19 +- shared/authentication/src/auth.client.ts | 8 +- shared/authentication/src/auth.definition.ts | 106 ++--------- shared/authentication/src/auth.middleware.ts | 56 +++--- shared/authentication/src/auth.roles.ts | 37 ++-- shared/authentication/src/email.ts | 23 +-- shared/authentication/src/index.ts | 6 +- shared/authentication/tsconfig.build.json | 9 +- shared/authentication/tsconfig.json | 28 +-- shared/authentication/tsup.config.ts | 4 +- shared/database/package.json | 2 +- shared/database/src/client.ts | 8 +- shared/database/src/index.ts | 6 +- shared/database/src/schema.ts | 17 +- shared/database/src/types/flowsheet.types.ts | 1 - shared/database/tsconfig.build.json | 9 +- shared/database/tsconfig.json | 26 +-- shared/database/tsup.config.ts | 4 +- tests/integration/discogs.spec.js | 9 +- tests/integration/djs.spec.js | 5 +- tests/integration/events.spec.js | 5 +- tests/integration/flowsheet.spec.js | 28 +-- tests/integration/library.spec.js | 5 +- tests/integration/metadata.spec.js | 22 +-- tests/integration/requestLine.spec.js | 28 +-- tests/mocks/database.mock.ts | 32 +++- .../legacy/flowsheet.mirror.test.ts | 55 ++++-- .../services/anonymousDevice.service.test.ts | 6 +- tests/unit/services/flowsheet.service.test.ts | 19 +- tests/unit/services/metadata.cache.test.ts | 5 +- tests/utils/db.js | 14 +- tests/utils/library_util.js | 1 - tests/utils/time.ts | 6 +- 88 files changed, 710 insertions(+), 1182 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b6e10a..5c633fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 with: - fetch-depth: 0 # Need full history for merge-base + fetch-depth: 0 # Need full history for merge-base - name: Get merge-base SHA id: merge-base @@ -152,7 +152,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 with: - fetch-depth: 0 # Need full history for --changedSince + fetch-depth: 0 # Need full history for --changedSince - name: Set Up Node.js uses: actions/setup-node@v4 diff --git a/apps/auth/app.ts b/apps/auth/app.ts index 642f5d6..db6f08a 100644 --- a/apps/auth/app.ts +++ b/apps/auth/app.ts @@ -44,11 +44,7 @@ if (process.env.NODE_ENV !== 'production') { const { eq, desc, like, and } = await import('drizzle-orm'); // First, look up the user by email to get their userId - const userResult = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, identifier)) - .limit(1); + const userResult = await db.select({ id: user.id }).from(user).where(eq(user.email, identifier)).limit(1); if (userResult.length === 0) { return res.status(404).json({ error: 'User not found with this email' }); @@ -59,10 +55,7 @@ if (process.env.NODE_ENV !== 'production') { const result = await db .select() .from(verification) - .where(and( - eq(verification.value, userId), - like(verification.identifier, `${tokenPrefix}%`) - )) + .where(and(eq(verification.value, userId), like(verification.identifier, `${tokenPrefix}%`))) .orderBy(desc(verification.createdAt)) .limit(1); @@ -72,9 +65,7 @@ if (process.env.NODE_ENV !== 'production') { // Extract the actual token from the identifier (e.g., "reset-password:abc123" -> "abc123") const fullIdentifier = result[0].identifier; - const token = fullIdentifier.startsWith(tokenPrefix) - ? fullIdentifier.slice(tokenPrefix.length) - : fullIdentifier; + const token = fullIdentifier.startsWith(tokenPrefix) ? fullIdentifier.slice(tokenPrefix.length) : fullIdentifier; res.json({ token, @@ -110,7 +101,9 @@ if (process.env.NODE_ENV !== 'production') { } }); - console.log('[TEST ENDPOINTS] Test helper endpoints enabled (/auth/test/verification-token, /auth/test/expire-session)'); + console.log( + '[TEST ENDPOINTS] Test helper endpoints enabled (/auth/test/verification-token, /auth/test/expire-session)' + ); } // Mount the Better Auth handler for all auth routes @@ -240,10 +233,7 @@ const createDefaultUser = async () => { // This ensures the user has admin permissions for Better Auth Admin plugin const { db, user } = await import('@wxyc/database'); const { eq } = await import('drizzle-orm'); - await db - .update(user) - .set({ role: 'admin' }) - .where(eq(user.id, newUser.id)); + await db.update(user).set({ role: 'admin' }).where(eq(user.id, newUser.id)); console.log('Default user created successfully with admin role.'); } catch (error) { @@ -257,7 +247,7 @@ const syncAdminRoles = async () => { try { const { db, user, member, organization } = await import('@wxyc/database'); const { eq, sql } = await import('drizzle-orm'); - + const defaultOrgSlug = process.env.DEFAULT_ORG_SLUG; if (!defaultOrgSlug) { console.log('[ADMIN PERMISSIONS] DEFAULT_ORG_SLUG not set, skipping admin role fix'); @@ -285,10 +275,7 @@ const syncAdminRoles = async () => { console.log(`[ADMIN PERMISSIONS] Found ${usersNeedingFix.length} users needing admin role fix: `); for (const u of usersNeedingFix) { console.log(`[ADMIN PERMISSIONS] - ${u.userEmail} (${u.memberRole}) - current role: ${u.userRole || 'null'}`); - await db - .update(user) - .set({ role: 'admin' }) - .where(eq(user.id, u.userId)); + await db.update(user).set({ role: 'admin' }).where(eq(user.id, u.userId)); console.log(`[ADMIN PERMISSIONS] - Fixed: ${u.userEmail} now has admin role`); } } else { diff --git a/apps/auth/tsconfig.json b/apps/auth/tsconfig.json index 7dbdb3d..39ec018 100644 --- a/apps/auth/tsconfig.json +++ b/apps/auth/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": ["../../tsconfig.base.json"], - "references": [{ "path": "../../shared/authentication" }], - "include": ["."], - "compilerOptions": { - "module": "esnext", - "moduleResolution": "bundler", - "paths": { - "@/*": ["./*"] - } + "extends": ["../../tsconfig.base.json"], + "references": [{ "path": "../../shared/authentication" }], + "include": ["."], + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@/*": ["./*"] } -} \ No newline at end of file + } +} diff --git a/apps/auth/tsup.config.ts b/apps/auth/tsup.config.ts index 4c7a7a2..b58553e 100644 --- a/apps/auth/tsup.config.ts +++ b/apps/auth/tsup.config.ts @@ -1,23 +1,16 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from 'tsup'; export default defineConfig((options) => ({ - entry: ["app.ts"], - outDir: "dist", - format: ["esm"], - platform: "node", - target: "node20", + entry: ['app.ts'], + outDir: 'dist', + format: ['esm'], + platform: 'node', + target: 'node20', clean: true, sourcemap: true, - external: [ - "@wxyc/database", - "better-auth", - "drizzle-orm", - "express", - "cors", - "postgres" - ], + external: ['@wxyc/database', 'better-auth', 'drizzle-orm', 'express', 'cors', 'postgres'], env: { - NODE_ENV: process.env.NODE_ENV || "development", + NODE_ENV: process.env.NODE_ENV || 'development', }, - onSuccess: options.watch ? "node ./dist/app.js" : undefined, -})); \ No newline at end of file + onSuccess: options.watch ? 'node ./dist/app.js' : undefined, +})); diff --git a/apps/backend/controllers/djs.controller.ts b/apps/backend/controllers/djs.controller.ts index 8e8cae7..1e160cf 100644 --- a/apps/backend/controllers/djs.controller.ts +++ b/apps/backend/controllers/djs.controller.ts @@ -1,6 +1,6 @@ import { RequestHandler } from 'express'; import * as DJService from '../services/djs.service'; -import { NewBinEntry } from "@wxyc/database"; +import { NewBinEntry } from '@wxyc/database'; export type binBody = { dj_id: string; diff --git a/apps/backend/controllers/events.conroller.ts b/apps/backend/controllers/events.conroller.ts index dc3b494..0acde73 100644 --- a/apps/backend/controllers/events.conroller.ts +++ b/apps/backend/controllers/events.conroller.ts @@ -1,10 +1,5 @@ -import { RequestHandler, Response } from "express"; -import { - serverEventsMgr, - TestEvents, - Topics, - type EventData, -} from "../utils/serverEvents"; +import { RequestHandler, Response } from 'express'; +import { serverEventsMgr, TestEvents, Topics, type EventData } from '../utils/serverEvents'; // Role constants for event authorization const ROLE_DJ = 'dj'; @@ -38,11 +33,7 @@ type regReqBody = { topics?: string[]; }; -export const registerEventClient: RequestHandler< - object, - unknown, - regReqBody -> = (req, res) => { +export const registerEventClient: RequestHandler = (req, res) => { const client = serverEventsMgr.registerClient(res); const topics = filterAuthorizedTopics(res, req.body.topics || []); @@ -55,27 +46,21 @@ type subReqBody = { topics: string[]; }; -export const subscribeToTopic: RequestHandler = ( - req, - res, - next -) => { +export const subscribeToTopic: RequestHandler = (req, res, next) => { const { client_id, topics } = req.body; if (!client_id || !topics) { return res.status(400).json({ - message: "Bad Request: client_id or topics missing from request body", + message: 'Bad Request: client_id or topics missing from request body', }); } try { const subbedTopics = serverEventsMgr.subscribe(topics, client_id); - res - .status(200) - .json({ message: "successfully subscribed", topics: subbedTopics }); + res.status(200).json({ message: 'successfully subscribed', topics: subbedTopics }); } catch (e) { - console.error("Failed to subscribe to event: ", e); + console.error('Failed to subscribe to event: ', e); return next(e); } @@ -85,7 +70,7 @@ export const testTrigger: RequestHandler = (req, res, next) => { const data: EventData = { type: TestEvents.test, payload: { - message: "This is a test message sent over sse", + message: 'This is a test message sent over sse', }, timestamp: new Date(), }; @@ -93,12 +78,12 @@ export const testTrigger: RequestHandler = (req, res, next) => { try { serverEventsMgr.broadcast(Topics.test, data); } catch (e) { - console.error("Failed to broadcast event: ", e); + console.error('Failed to broadcast event: ', e); return next(e); } res.status(200).json({ - message: "event triggered", + message: 'event triggered', }); }; diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index 5c1c8bc..6a5adc6 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 { NewFSEntry, FSEntry, Show, ShowDJ, library } from '@wxyc/database'; import * as flowsheet_service from '../services/flowsheet.service.js'; import { fetchAndCacheMetadata } from '../services/metadata/index.js'; diff --git a/apps/backend/controllers/library.controller.ts b/apps/backend/controllers/library.controller.ts index 588c82a..587628b 100644 --- a/apps/backend/controllers/library.controller.ts +++ b/apps/backend/controllers/library.controller.ts @@ -8,7 +8,7 @@ import { NewGenre, NewRotationRelease, RotationRelease, -} from "@wxyc/database"; +} from '@wxyc/database'; import * as libraryService from '../services/library.service.js'; type NewAlbumRequest = { diff --git a/apps/backend/controllers/schedule.controller.ts b/apps/backend/controllers/schedule.controller.ts index f456b87..48b4153 100644 --- a/apps/backend/controllers/schedule.controller.ts +++ b/apps/backend/controllers/schedule.controller.ts @@ -1,6 +1,6 @@ import { Request, RequestHandler } from 'express'; import * as ScheduleService from '../services/schedule.service.js'; -import { NewShift } from "@wxyc/database"; +import { NewShift } from '@wxyc/database'; export const getSchedule: RequestHandler = async (req, res, next) => { try { diff --git a/apps/backend/middleware/conditionalGet.ts b/apps/backend/middleware/conditionalGet.ts index 4e85114..5f7920e 100644 --- a/apps/backend/middleware/conditionalGet.ts +++ b/apps/backend/middleware/conditionalGet.ts @@ -7,11 +7,7 @@ import * as flowsheet_service from '../services/flowsheet.service.js'; * Returns 304 Not Modified if data hasn't changed since the client's timestamp. * Sets `Last-Modified` header on responses for client caching. */ -export const conditionalGet: RequestHandler = ( - req: Request, - res: Response, - next: NextFunction -): void => { +export const conditionalGet: RequestHandler = (req: Request, res: Response, next: NextFunction): void => { const lastModified = flowsheet_service.getLastModifiedAt(); // Check query param first, then header diff --git a/apps/backend/middleware/legacy/commandqueue.mirror.ts b/apps/backend/middleware/legacy/commandqueue.mirror.ts index 29c0eb8..f11cbf4 100644 --- a/apps/backend/middleware/legacy/commandqueue.mirror.ts +++ b/apps/backend/middleware/legacy/commandqueue.mirror.ts @@ -1,22 +1,18 @@ -import { - EventData, - MirrorEvents, - serverEventsMgr, -} from "../../utils/serverEvents"; - -import { promises } from "fs"; -import { EventEmitter } from "node:events"; -import path from "path"; -import { MirrorSQL } from "./sql.mirror"; -import { cryptoRandomId, expBackoffMs } from "./utilities.mirror"; +import { EventData, MirrorEvents, serverEventsMgr } from '../../utils/serverEvents'; + +import { promises } from 'fs'; +import { EventEmitter } from 'node:events'; +import path from 'path'; +import { MirrorSQL } from './sql.mirror'; +import { cryptoRandomId, expBackoffMs } from './utilities.mirror'; const CommandQueueEvents = { - enqueued: "enqueued", - started: "started", - succeeded: "succeeded", - failedAttempt: "failedAttempt", - fatal: "fatal", - persisted: "persisted", + enqueued: 'enqueued', + started: 'started', + succeeded: 'succeeded', + failedAttempt: 'failedAttempt', + fatal: 'fatal', + persisted: 'persisted', } as const; export interface MirrorCommand { @@ -26,12 +22,7 @@ export interface MirrorCommand { attempts: number; lastResult?: string; lastError?: string; - status: - | "pending" - | "in_progress" - | "in_progress_retrying" - | "completed" - | "failed"; + status: 'pending' | 'in_progress' | 'in_progress_retrying' | 'completed' | 'failed'; } export interface MirrorQueueOptions { @@ -57,29 +48,28 @@ export class MirrorCommandQueue extends EventEmitter { if (!this._instance) { this._instance = new MirrorCommandQueue(options); - this._instance.on(CommandQueueEvents.enqueued, this.createTrigger("syncStarted")); - this._instance.on(CommandQueueEvents.started, this.createTrigger("syncProgress")); - this._instance.on(CommandQueueEvents.succeeded, this.createTrigger("syncComplete")); - this._instance.on(CommandQueueEvents.failedAttempt, this.createTrigger("syncRetry")); - this._instance.on(CommandQueueEvents.fatal, this.createTrigger("syncError")); - this._instance.on(CommandQueueEvents.persisted, this.createTrigger("syncError")); + this._instance.on(CommandQueueEvents.enqueued, this.createTrigger('syncStarted')); + this._instance.on(CommandQueueEvents.started, this.createTrigger('syncProgress')); + this._instance.on(CommandQueueEvents.succeeded, this.createTrigger('syncComplete')); + this._instance.on(CommandQueueEvents.failedAttempt, this.createTrigger('syncRetry')); + this._instance.on(CommandQueueEvents.fatal, this.createTrigger('syncError')); + this._instance.on(CommandQueueEvents.persisted, this.createTrigger('syncError')); } return this._instance; } - private static createTrigger = - (eventType: keyof typeof MirrorEvents) => (cmd: MirrorCommand) => { - const data: EventData = { - type: eventType, - payload: cmd, - timestamp: new Date(), - }; - - serverEventsMgr.broadcast("mirror", data); - console.table(cmd); + private static createTrigger = (eventType: keyof typeof MirrorEvents) => (cmd: MirrorCommand) => { + const data: EventData = { + type: eventType, + payload: cmd, + timestamp: new Date(), }; + serverEventsMgr.broadcast('mirror', data); + console.table(cmd); + }; + private readonly options: Required; private readonly queue: MirrorCommand[] = []; private working = false; @@ -93,15 +83,13 @@ export class MirrorCommandQueue extends EventEmitter { baseBackoffMs: options?.baseBackoffMs ?? 250, maxBackoffMs: options?.maxBackoffMs ?? 30_000, jitterMs: options?.jitterMs ?? 0.2, - logFile: options?.logFile ?? path.resolve(process.cwd(), "mirror-logs"), + logFile: options?.logFile ?? path.resolve(process.cwd(), 'mirror-logs'), }; } enqueue(sqls: string[]): MirrorCommand | null { if (!this.alive) return null; - let sql = sqls - .map((s) => (s.trim().endsWith(";") ? s.trim() : s.trim() + ";")) - .join("\n"); + let sql = sqls.map((s) => (s.trim().endsWith(';') ? s.trim() : s.trim() + ';')).join('\n'); sql = `START TRANSACTION;\n${sql}\nCOMMIT;`; const cmd: MirrorCommand = { @@ -109,7 +97,7 @@ export class MirrorCommandQueue extends EventEmitter { sql, enqueuedAt: Date.now(), attempts: 0, - status: "pending", + status: 'pending', }; this.queue.push(cmd); @@ -150,15 +138,15 @@ export class MirrorCommandQueue extends EventEmitter { try { cmd.attempts += 1; - cmd.status = "in_progress"; + cmd.status = 'in_progress'; this.emit(CommandQueueEvents.started, cmd); cmd.lastResult = await MirrorSQL.instance().send(cmd.sql); - cmd.status = "completed"; + cmd.status = 'completed'; this.emit(CommandQueueEvents.succeeded, cmd); } catch (err) { - cmd.status = "failed"; + cmd.status = 'failed'; cmd.lastError = String((err as Error).message || err); await this.handleFailure(cmd, err as Error); } @@ -173,14 +161,11 @@ export class MirrorCommandQueue extends EventEmitter { this.emit(CommandQueueEvents.failedAttempt, { cmd, error: err, attempt: cmd.attempts }); if (cmd.attempts >= this.options.maxAttempts) { - await this.fatalStop( - cmd, - `Exceeded maxAttempts=${this.options.maxAttempts}` - ); + await this.fatalStop(cmd, `Exceeded maxAttempts=${this.options.maxAttempts}`); return; } - cmd.status = "in_progress_retrying"; + cmd.status = 'in_progress_retrying'; const delay = expBackoffMs( cmd.attempts, this.options.baseBackoffMs, @@ -209,21 +194,18 @@ export class MirrorCommandQueue extends EventEmitter { private async persistQueue(reason: string, failedCommand?: MirrorCommand) { await promises.mkdir(this.options.logFile, { recursive: true }); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const logFile = path.join( - this.options.logFile, - `queue-fatal-${timestamp}.json` - ); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFile = path.join(this.options.logFile, `queue-fatal-${timestamp}.json`); const info: FatalInfo = { failedCommand: failedCommand ?? { - id: "n/a", - sql: "n/a", - status: "failed", + id: 'n/a', + sql: 'n/a', + status: 'failed', enqueuedAt: 0, attempts: 0, - lastResult: "n/a", - lastError: "n/a", + lastResult: 'n/a', + lastError: 'n/a', }, pendingQueue: [...this.queue], reason, @@ -232,7 +214,7 @@ export class MirrorCommandQueue extends EventEmitter { }; const payload = JSON.stringify(info, null, 2); - await promises.writeFile(logFile, payload, "utf8"); + await promises.writeFile(logFile, payload, 'utf8'); this.emit(CommandQueueEvents.persisted, info); return info; } diff --git a/apps/backend/middleware/legacy/flowsheet.mirror.ts b/apps/backend/middleware/legacy/flowsheet.mirror.ts index abcf3ad..941a764 100644 --- a/apps/backend/middleware/legacy/flowsheet.mirror.ts +++ b/apps/backend/middleware/legacy/flowsheet.mirror.ts @@ -1,24 +1,22 @@ -import { QueryParams } from "../../controllers/flowsheet.controller"; -import { db } from "@wxyc/database"; -import { user, flowsheet, FSEntry, Show } from "@wxyc/database" -import { asc, desc, eq } from "drizzle-orm"; -import { Request } from "express"; -import { createBackendMirrorMiddleware } from "./mirror.middleware.js"; -import { safeSql, safeSqlNum, toMs } from "./utilities.mirror.js"; +import { QueryParams } from '../../controllers/flowsheet.controller'; +import { db } from '@wxyc/database'; +import { user, flowsheet, FSEntry, Show } from '@wxyc/database'; +import { asc, desc, eq } from 'drizzle-orm'; +import { Request } from 'express'; +import { createBackendMirrorMiddleware } from './mirror.middleware.js'; +import { safeSql, safeSqlNum, toMs } from './utilities.mirror.js'; -const FLOWSHEET_ENTRY_TABLE = "FLOWSHEET_ENTRY_PROD"; -const RADIO_SHOW_TABLE = "FLOWSHEET_RADIO_SHOW_PROD"; +const FLOWSHEET_ENTRY_TABLE = 'FLOWSHEET_ENTRY_PROD'; +const RADIO_SHOW_TABLE = 'FLOWSHEET_RADIO_SHOW_PROD'; const getEntries = createBackendMirrorMiddleware(async (req, data) => { const query = req.query as QueryParams; - const page = parseInt(query.page ?? "0"); - const limit = parseInt(query.limit ?? "30"); + const page = parseInt(query.page ?? '0'); + const limit = parseInt(query.limit ?? '30'); const offset = page * limit; - return [ - `SELECT * FROM ${FLOWSHEET_ENTRY_TABLE} LIMIT ${limit} OFFSET ${offset};`, - ]; + return [`SELECT * FROM ${FLOWSHEET_ENTRY_TABLE} LIMIT ${limit} OFFSET ${offset};`]; }); const startShow = createBackendMirrorMiddleware(async (req, show) => { @@ -32,7 +30,11 @@ const startShow = createBackendMirrorMiddleware(async (req, show) => { if (!show) return statements; // no show, nothing to do const dj = ( - await db.select().from(user).where(eq(user.id, djId as string)).limit(1) + await db + .select() + .from(user) + .where(eq(user.id, djId as string)) + .limit(1) )?.[0]; if (!dj) return statements; // DJ not found @@ -66,7 +68,7 @@ const startShow = createBackendMirrorMiddleware(async (req, show) => { ${safeSqlNum(timeModified)}, -- TIME_LAST_MODIFIED ${safeSqlNum(timeCreated)}, -- TIME_CREATED 0, -- MODLOCK (0 for active, 1 when completed) - @NEW_RS_ID);` // SHOW_ID (same as ID) + @NEW_RS_ID);` // SHOW_ID (same as ID) ); const announcementEntry = await db @@ -83,14 +85,13 @@ const startShow = createBackendMirrorMiddleware(async (req, show) => { return statements; }); -export const endShow = createBackendMirrorMiddleware( - async (req, show) => { - const endMs = toMs(show.end_time ?? Date.now()); - const statements: string[] = []; +export const endShow = createBackendMirrorMiddleware(async (req, show) => { + const endMs = toMs(show.end_time ?? Date.now()); + const statements: string[] = []; - // Update the most recent active show (SIGNOFF_TIME = 0, MODLOCK = 0) - statements.push( - `UPDATE ${RADIO_SHOW_TABLE} + // Update the most recent active show (SIGNOFF_TIME = 0, MODLOCK = 0) + statements.push( + `UPDATE ${RADIO_SHOW_TABLE} SET SIGNOFF_TIME = ${safeSqlNum(endMs)}, TIME_LAST_MODIFIED = ${safeSqlNum(endMs)}, MODLOCK = 1 @@ -98,27 +99,24 @@ export const endShow = createBackendMirrorMiddleware( AND MODLOCK = 0 ORDER BY STARTING_RADIO_HOUR DESC LIMIT 1;` - ); - - const announcementEntry = await db - .select() - .from(flowsheet) - .where(eq(flowsheet.show_id, show.id)) - .orderBy(desc(flowsheet.play_order)) - .limit(1); + ); - if (announcementEntry && announcementEntry.length > 0) { - statements.push(...(await getAddEntrySQL(req, announcementEntry[0]))); - } + const announcementEntry = await db + .select() + .from(flowsheet) + .where(eq(flowsheet.show_id, show.id)) + .orderBy(desc(flowsheet.play_order)) + .limit(1); - return statements; + if (announcementEntry && announcementEntry.length > 0) { + statements.push(...(await getAddEntrySQL(req, announcementEntry[0]))); } -); + + return statements; +}); const getAddEntrySQL = async (req: Request, entry: FSEntry) => { - const startMs = entry?.add_time - ? new Date(entry.add_time).getTime() - : Date.now(); + const startMs = entry?.add_time ? new Date(entry.add_time).getTime() : Date.now(); const radioHour = Math.floor(startMs / 3_600_000) * 3_600_000; const statements: string[] = []; @@ -155,11 +153,16 @@ const getAddEntrySQL = async (req: Request, entry: FSEntry) => { 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')) { - + 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 const nowPlayingFlag = 0; @@ -176,23 +179,23 @@ const getAddEntrySQL = async (req: Request, entry: FSEntry) => { entryTypeCode = 7; // Map to talkset in legacy } else if (entryType === 'talkset' || entryType === 'message') { entryTypeCode = 7; - message = "------ talkset -------"; + 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")) { + if (message.toLowerCase().includes('breakpoint')) { entryTypeCode = 8; message = message.toUpperCase(); - } else if (message.toLowerCase().includes("start of show") || message.toLowerCase().includes("signed on")) { + } 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")) { + } else if (message.toLowerCase().includes('end of show') || message.toLowerCase().includes('signed off')) { entryTypeCode = 10; startTime = startMs; } else { - message = "------ talkset -------"; + message = '------ talkset -------'; } } @@ -274,34 +277,31 @@ const getAddEntrySQL = async (req: Request, entry: FSEntry) => { export const addEntry = createBackendMirrorMiddleware(getAddEntrySQL); -export const updateEntry = createBackendMirrorMiddleware( - async (req, entry) => { - // Message-only rows aren't updateable - if (entry?.message && entry.message.trim() !== "") return []; +export const updateEntry = createBackendMirrorMiddleware(async (req, entry) => { + // Message-only rows aren't updateable + if (entry?.message && entry.message.trim() !== '') return []; - const nowMs = Date.now(); - const statements: string[] = []; - - // Resolve the RADIO_SHOW_ID first - statements.push( - `SET @RS_ID := (SELECT IFNULL(MAX(ID), 0) FROM ${RADIO_SHOW_TABLE});` - ); + const nowMs = Date.now(); + const statements: string[] = []; - // 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; - if (entry.rotation_id && entry.rotation_id > 0) { - // Rotation entries - default to type 2 (general rotation) - // Would need rotation type lookup for accurate 1-4 classification - entryTypeCode = 2; - } else if (entry.album_id && entry.album_id > 0) { - entryTypeCode = 6; // Library entry - } + // Resolve the RADIO_SHOW_ID first + statements.push(`SET @RS_ID := (SELECT IFNULL(MAX(ID), 0) FROM ${RADIO_SHOW_TABLE});`); + + // 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; + if (entry.rotation_id && entry.rotation_id > 0) { + // Rotation entries - default to type 2 (general rotation) + // Would need rotation type lookup for accurate 1-4 classification + entryTypeCode = 2; + } else if (entry.album_id && entry.album_id > 0) { + entryTypeCode = 6; // Library entry + } - // Update by RADIO_SHOW_ID and SEQUENCE_WITHIN_SHOW - // GLOBAL_ORDER_ID is calculated as RADIO_SHOW_ID * 1000 + SEQUENCE_WITHIN_SHOW - statements.push( - `UPDATE ${FLOWSHEET_ENTRY_TABLE} + // Update by RADIO_SHOW_ID and SEQUENCE_WITHIN_SHOW + // GLOBAL_ORDER_ID is calculated as RADIO_SHOW_ID * 1000 + SEQUENCE_WITHIN_SHOW + statements.push( + `UPDATE ${FLOWSHEET_ENTRY_TABLE} SET ARTIST_NAME = ${safeSql(entry.artist_name)}, SONG_TITLE = ${safeSql(entry.track_title)}, RELEASE_TITLE = ${safeSql(entry.album_title)}, @@ -314,32 +314,27 @@ export const updateEntry = createBackendMirrorMiddleware( WHERE RADIO_SHOW_ID = @RS_ID AND SEQUENCE_WITHIN_SHOW = ${safeSqlNum(entry.play_order)} LIMIT 1;` - ); + ); - return statements; - } -); + return statements; +}); -export const deleteEntry = createBackendMirrorMiddleware( - async (req, removed) => { - const statements: string[] = []; +export const deleteEntry = createBackendMirrorMiddleware(async (req, removed) => { + const statements: string[] = []; - // Resolve the RADIO_SHOW_ID first - statements.push( - `SET @RS_ID := (SELECT IFNULL(MAX(ID), 0) FROM ${RADIO_SHOW_TABLE});` - ); + // Resolve the RADIO_SHOW_ID first + statements.push(`SET @RS_ID := (SELECT IFNULL(MAX(ID), 0) FROM ${RADIO_SHOW_TABLE});`); - // Delete by RADIO_SHOW_ID and SEQUENCE_WITHIN_SHOW - statements.push( - `DELETE FROM ${FLOWSHEET_ENTRY_TABLE} + // Delete by RADIO_SHOW_ID and SEQUENCE_WITHIN_SHOW + statements.push( + `DELETE FROM ${FLOWSHEET_ENTRY_TABLE} WHERE RADIO_SHOW_ID = @RS_ID AND SEQUENCE_WITHIN_SHOW = ${safeSqlNum(removed.play_order)} LIMIT 1;` - ); + ); - return statements; - } -); + return statements; +}); /* export const changeOrder = createBackendMirrorMiddleware( diff --git a/apps/backend/middleware/legacy/mirror.middleware.ts b/apps/backend/middleware/legacy/mirror.middleware.ts index 9343484..f126f1a 100644 --- a/apps/backend/middleware/legacy/mirror.middleware.ts +++ b/apps/backend/middleware/legacy/mirror.middleware.ts @@ -1,7 +1,7 @@ -import { NextFunction, Request, Response } from "express"; -import { MirrorCommandQueue } from "./commandqueue.mirror"; +import { NextFunction, Request, Response } from 'express'; +import { MirrorCommandQueue } from './commandqueue.mirror'; -import { PostHog } from "posthog-node"; +import { PostHog } from 'posthog-node'; export const createBackendMirrorMiddleware = (createCommand: (req: Request, data: T) => Promise) => @@ -9,38 +9,33 @@ export const createBackendMirrorMiddleware = tapJsonResponse(res); // After the response is sent, decide whether to enqueue work - res.once("finish", () => { + res.once('finish', () => { void (async () => { try { - const postHogClient = new PostHog(process.env.POSTHOG_API_KEY ?? "", { - host: "https://us.i.posthog.com", + const postHogClient = new PostHog(process.env.POSTHOG_API_KEY ?? '', { + host: 'https://us.i.posthog.com', }); - console.log("Response finished, checking for mirror work..."); + console.log('Response finished, checking for mirror work...'); const ok = res.statusCode >= 200 && res.statusCode < 305; const data = (res.locals as any).mirrorData as T | undefined; - console.log("Response status:", res.statusCode, "ok?", ok); + console.log('Response status:', res.statusCode, 'ok?', ok); - const distinctId = (req as any).user?.id ?? req.ip ?? "anonymous"; + const distinctId = (req as any).user?.id ?? req.ip ?? 'anonymous'; let mirrorOn = await postHogClient.isFeatureEnabled('backend-mirror', distinctId); mirrorOn ??= false; - if ( - !ok || - data == null || - !mirrorOn - ) - return; + if (!ok || data == null || !mirrorOn) return; - console.log("Enqueuing mirror work..."); + console.log('Enqueuing mirror work...'); const queue = MirrorCommandQueue.instance(); queue.enqueue(await createCommand(req, data)); await postHogClient.shutdown(); } catch (e) { - console.error("Error in mirror middleware:", e); + console.error('Error in mirror middleware:', e); } })(); }); @@ -54,8 +49,8 @@ function tapJsonResponse(res: Response) { res.send = ((body?: any) => { let captured: unknown = body; - const ct = (res.getHeader("content-type") || "").toString().toLowerCase(); - if (typeof body === "string" && ct.includes("application/json")) { + const ct = (res.getHeader('content-type') || '').toString().toLowerCase(); + if (typeof body === 'string' && ct.includes('application/json')) { try { captured = JSON.parse(body); } catch { @@ -63,9 +58,9 @@ function tapJsonResponse(res: Response) { } } - if (Buffer.isBuffer(body) && ct.includes("application/json")) { + if (Buffer.isBuffer(body) && ct.includes('application/json')) { try { - captured = JSON.parse(body.toString("utf8")); + captured = JSON.parse(body.toString('utf8')); } catch { /* ignore */ } diff --git a/apps/backend/middleware/legacy/sql.mirror.ts b/apps/backend/middleware/legacy/sql.mirror.ts index 908a87e..95c91c9 100644 --- a/apps/backend/middleware/legacy/sql.mirror.ts +++ b/apps/backend/middleware/legacy/sql.mirror.ts @@ -1,4 +1,4 @@ -import { Config, NodeSSH } from "node-ssh"; +import { Config, NodeSSH } from 'node-ssh'; export class MirrorSQL { private static _instance: MirrorSQL | null = null; @@ -38,14 +38,14 @@ export class MirrorSQL { } private static shSingleQuote = (s: string) => s.replace(/'/g, `'\\''`); - private static getRemotePwd = () => process.env.REMOTE_DB_PASSWORD ?? ""; + private static getRemotePwd = () => process.env.REMOTE_DB_PASSWORD ?? ''; private static makeSqlCommand = (sql: string) => ` MYSQL_PWD='${MirrorSQL.shSingleQuote(MirrorSQL.getRemotePwd())}' \\ - mysql -u ${process.env.REMOTE_DB_USER ?? ""} \\ - -h ${process.env.REMOTE_DB_HOST ?? ""} \\ - -D ${process.env.REMOTE_DB_NAME ?? ""} \\ + mysql -u ${process.env.REMOTE_DB_USER ?? ''} \\ + -h ${process.env.REMOTE_DB_HOST ?? ''} \\ + -D ${process.env.REMOTE_DB_NAME ?? ''} \\ --protocol=TCP \\ --connect-timeout=10 \\ --skip-ssl \\ @@ -55,8 +55,7 @@ __SQL__ `.trim(); async send(sql: string) { - if (!sql || sql.length == 0) - throw new Error("Empty SQL generated by createSql(req)."); + if (!sql || sql.length == 0) throw new Error('Empty SQL generated by createSql(req).'); const ssh = await MirrorSQL.sshInstance(); const mysqlCommand = MirrorSQL.makeSqlCommand(sql.trim()); diff --git a/apps/backend/middleware/legacy/utilities.mirror.ts b/apps/backend/middleware/legacy/utilities.mirror.ts index 82e2cb1..4edae5c 100644 --- a/apps/backend/middleware/legacy/utilities.mirror.ts +++ b/apps/backend/middleware/legacy/utilities.mirror.ts @@ -1,9 +1,7 @@ - export function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } - export function expBackoffMs(attempt: number, base: number, max: number, jitterRatio: number) { const pure = Math.min(max, base * 2 ** (attempt - 1)); const jitter = pure * jitterRatio; @@ -14,19 +12,18 @@ export function expBackoffMs(attempt: number, base: number, max: number, jitterR export function cryptoRandomId() { // Node 18+: crypto.randomUUID() available; fallback otherwise try { - if ( - typeof crypto !== "undefined" && - typeof (crypto as { randomUUID?: () => string }).randomUUID === "function" - ) { + if (typeof crypto !== 'undefined' && typeof (crypto as { randomUUID?: () => string }).randomUUID === 'function') { return (crypto as { randomUUID: () => string }).randomUUID(); } - } catch { /* ignore */ } - return "cmd_" + Math.random().toString(36).slice(2) + Date.now().toString(36); + } catch { + /* ignore */ + } + return 'cmd_' + Math.random().toString(36).slice(2) + Date.now().toString(36); } export const toMs = (v: unknown, fallback = Date.now()): number => { - if (typeof v === "number" && Number.isFinite(v)) return Math.floor(v); - if (typeof v === "string") { + if (typeof v === 'number' && Number.isFinite(v)) return Math.floor(v); + if (typeof v === 'string') { const iso = Date.parse(v); if (Number.isFinite(iso)) return Math.floor(iso); const num = Number(v); @@ -35,12 +32,12 @@ export const toMs = (v: unknown, fallback = Date.now()): number => { return Math.floor(fallback); }; -export const safeSql = (s?: string | null) => - s === undefined || s === null ? "NULL" : `'${String(s).replace(/'/g, "''")}'`; +export const safeSql = (s?: string | null) => + s === undefined || s === null ? 'NULL' : `'${String(s).replace(/'/g, "''")}'`; /** * Floors valid numbers and returns their string representation for SQL. * Returns the string "NULL" for invalid inputs (non-numbers or non-finite values). * Intended for safe SQL number generation. */ export const safeSqlNum = (n: unknown): string => - typeof n === "number" && Number.isFinite(n) ? String(Math.floor(n)) : "NULL"; \ No newline at end of file + typeof n === 'number' && Number.isFinite(n) ? String(Math.floor(n)) : 'NULL'; diff --git a/apps/backend/routes/djs.route.ts b/apps/backend/routes/djs.route.ts index 6d2fd4d..a5ce601 100644 --- a/apps/backend/routes/djs.route.ts +++ b/apps/backend/routes/djs.route.ts @@ -1,29 +1,13 @@ -import { requirePermissions } from "@wxyc/authentication"; -import { Router } from "express"; -import * as djsController from "../controllers/djs.controller.js"; +import { requirePermissions } from '@wxyc/authentication'; +import { Router } from 'express'; +import * as djsController from '../controllers/djs.controller.js'; export const dj_route = Router(); -dj_route.post( - "/bin", - requirePermissions({ bin: ["write"] }), - djsController.addToBin -); +dj_route.post('/bin', requirePermissions({ bin: ['write'] }), djsController.addToBin); -dj_route.delete( - "/bin", - requirePermissions({ bin: ["write"] }), - djsController.deleteFromBin -); +dj_route.delete('/bin', requirePermissions({ bin: ['write'] }), djsController.deleteFromBin); -dj_route.get( - "/bin", - requirePermissions({ bin: ["read"] }), - djsController.getBin -); +dj_route.get('/bin', requirePermissions({ bin: ['read'] }), djsController.getBin); -dj_route.get( - "/playlists", - requirePermissions({ flowsheet: ["read"] }), - djsController.getPlaylistsForDJ -); +dj_route.get('/playlists', requirePermissions({ flowsheet: ['read'] }), djsController.getPlaylistsForDJ); diff --git a/apps/backend/routes/events.route.ts b/apps/backend/routes/events.route.ts index d7015e6..13148c8 100644 --- a/apps/backend/routes/events.route.ts +++ b/apps/backend/routes/events.route.ts @@ -1,24 +1,12 @@ -import { requirePermissions } from "@wxyc/authentication"; -import { Router } from "express"; -import * as serverEvents from "../controllers/events.conroller.js"; +import { requirePermissions } from '@wxyc/authentication'; +import { Router } from 'express'; +import * as serverEvents from '../controllers/events.conroller.js'; export const events_route = Router(); //TODO: secure - mgmt & individual dj -events_route.post( - "/register", - requirePermissions({ flowsheet: ["read"] }), - serverEvents.registerEventClient -); +events_route.post('/register', requirePermissions({ flowsheet: ['read'] }), serverEvents.registerEventClient); -events_route.put( - "/subscribe", - requirePermissions({ flowsheet: ["read"] }), - serverEvents.subscribeToTopic -); +events_route.put('/subscribe', requirePermissions({ flowsheet: ['read'] }), serverEvents.subscribeToTopic); -events_route.get( - "/test", - requirePermissions({ flowsheet: ["read"] }), - serverEvents.testTrigger -); +events_route.get('/test', requirePermissions({ flowsheet: ['read'] }), serverEvents.testTrigger); diff --git a/apps/backend/routes/flowsheet.route.ts b/apps/backend/routes/flowsheet.route.ts index 2f32c7f..38385dc 100644 --- a/apps/backend/routes/flowsheet.route.ts +++ b/apps/backend/routes/flowsheet.route.ts @@ -1,66 +1,61 @@ -import { requirePermissions } from "@wxyc/authentication"; -import { Router } from "express"; -import * as flowsheetController from "../controllers/flowsheet.controller"; -import { flowsheetMirror } from "../middleware/legacy/flowsheet.mirror"; -import { conditionalGet } from "../middleware/conditionalGet"; +import { requirePermissions } from '@wxyc/authentication'; +import { Router } from 'express'; +import * as flowsheetController from '../controllers/flowsheet.controller'; +import { flowsheetMirror } from '../middleware/legacy/flowsheet.mirror'; +import { conditionalGet } from '../middleware/conditionalGet'; export const flowsheet_route = Router(); -flowsheet_route.get( - "/", - conditionalGet, - flowsheetMirror.getEntries, - flowsheetController.getEntries -); +flowsheet_route.get('/', conditionalGet, flowsheetMirror.getEntries, flowsheetController.getEntries); flowsheet_route.post( - "/", - requirePermissions({ flowsheet: ["write"] }), + '/', + requirePermissions({ flowsheet: ['write'] }), flowsheetMirror.addEntry, flowsheetController.addEntry ); flowsheet_route.patch( - "/", - requirePermissions({ flowsheet: ["write"] }), + '/', + requirePermissions({ flowsheet: ['write'] }), flowsheetMirror.updateEntry, flowsheetController.updateEntry ); flowsheet_route.delete( - "/", - requirePermissions({ flowsheet: ["write"] }), + '/', + requirePermissions({ flowsheet: ['write'] }), flowsheetMirror.deleteEntry, flowsheetController.deleteEntry ); flowsheet_route.patch( - "/play-order", - requirePermissions({ flowsheet: ["write"] }), + '/play-order', + requirePermissions({ flowsheet: ['write'] }), /*flowsheetMirror.changeOrder,*/ flowsheetController.changeOrder ); -flowsheet_route.get("/latest", conditionalGet, flowsheetController.getLatest); +flowsheet_route.get('/latest', conditionalGet, flowsheetController.getLatest); flowsheet_route.post( - "/join", - requirePermissions({ flowsheet: ["write"] }), + '/join', + requirePermissions({ flowsheet: ['write'] }), flowsheetMirror.startShow, flowsheetController.joinShow ); flowsheet_route.post( - "/end", - requirePermissions({ flowsheet: ["write"] }), + '/end', + requirePermissions({ flowsheet: ['write'] }), flowsheetMirror.endShow, flowsheetController.leaveShow ); -flowsheet_route.get("/djs-on-air", flowsheetController.getDJList); +flowsheet_route.get('/djs-on-air', flowsheetController.getDJList); -flowsheet_route.get("/on-air", flowsheetController.getOnAir); +flowsheet_route.get('/on-air', flowsheetController.getOnAir); -flowsheet_route.get("/playlist", flowsheetController.getShowInfo); +flowsheet_route.get('/playlist', flowsheetController.getShowInfo); -flowsheet_route.get("/show-info", flowsheetController.getShowInfo); +flowsheet_route.get('/show-info', flowsheetController.getShowInfo); diff --git a/apps/backend/routes/flowsheet.v2.route.ts b/apps/backend/routes/flowsheet.v2.route.ts index 3f8a9a5..7bab772 100644 --- a/apps/backend/routes/flowsheet.v2.route.ts +++ b/apps/backend/routes/flowsheet.v2.route.ts @@ -1,7 +1,7 @@ -import { Router } from "express"; -import * as flowsheetV2Controller from "../controllers/flowsheet.v2.controller.js"; +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); +flowsheet_v2_route.get('/playlist', flowsheetV2Controller.getShowInfo); diff --git a/apps/backend/routes/library.route.ts b/apps/backend/routes/library.route.ts index f7f5ea9..0d65147 100644 --- a/apps/backend/routes/library.route.ts +++ b/apps/backend/routes/library.route.ts @@ -1,81 +1,33 @@ -import { requirePermissions } from "@wxyc/authentication"; -import { Router } from "express"; -import * as libraryController from "../controllers/library.controller.js"; -import * as requestLineController from "../controllers/requestLine.controller.js"; -import { requireAnonymousAuth } from "../middleware/anonymousAuth.js"; +import { requirePermissions } from '@wxyc/authentication'; +import { Router } from 'express'; +import * as libraryController from '../controllers/library.controller.js'; +import * as requestLineController from '../controllers/requestLine.controller.js'; +import { requireAnonymousAuth } from '../middleware/anonymousAuth.js'; export const library_route = Router(); // Public library search endpoint (for request line feature) // Uses anonymous auth instead of DJ permissions -library_route.get( - "/search", - requireAnonymousAuth, - requestLineController.searchLibraryEndpoint -); +library_route.get('/search', requireAnonymousAuth, requestLineController.searchLibraryEndpoint); -library_route.get( - "/", - requirePermissions({ catalog: ["read"] }), - libraryController.searchForAlbum -); +library_route.get('/', requirePermissions({ catalog: ['read'] }), libraryController.searchForAlbum); -library_route.post( - "/", - requirePermissions({ catalog: ["write"] }), - libraryController.addAlbum -); +library_route.post('/', requirePermissions({ catalog: ['write'] }), libraryController.addAlbum); -library_route.get( - "/rotation", - requirePermissions({ catalog: ["read"] }), - libraryController.getRotation -); +library_route.get('/rotation', requirePermissions({ catalog: ['read'] }), libraryController.getRotation); -library_route.post( - "/rotation", - requirePermissions({ catalog: ["write"] }), - libraryController.addRotation -); +library_route.post('/rotation', requirePermissions({ catalog: ['write'] }), libraryController.addRotation); -library_route.patch( - "/rotation", - requirePermissions({ catalog: ["write"] }), - libraryController.killRotation -); +library_route.patch('/rotation', requirePermissions({ catalog: ['write'] }), libraryController.killRotation); -library_route.post( - "/artists", - requirePermissions({ catalog: ["write"] }), - libraryController.addArtist -); +library_route.post('/artists', requirePermissions({ catalog: ['write'] }), libraryController.addArtist); -library_route.get( - "/formats", - requirePermissions({ catalog: ["read"] }), - libraryController.getFormats -); +library_route.get('/formats', requirePermissions({ catalog: ['read'] }), libraryController.getFormats); -library_route.post( - "/formats", - requirePermissions({ catalog: ["write"] }), - libraryController.addFormat -); +library_route.post('/formats', requirePermissions({ catalog: ['write'] }), libraryController.addFormat); -library_route.get( - "/genres", - requirePermissions({ catalog: ["read"] }), - libraryController.getGenres -); +library_route.get('/genres', requirePermissions({ catalog: ['read'] }), libraryController.getGenres); -library_route.post( - "/genres", - requirePermissions({ catalog: ["write"] }), - libraryController.addGenre -); +library_route.post('/genres', requirePermissions({ catalog: ['write'] }), libraryController.addGenre); -library_route.get( - "/info", - requirePermissions({ catalog: ["read"] }), - libraryController.getAlbum -); +library_route.get('/info', requirePermissions({ catalog: ['read'] }), libraryController.getAlbum); diff --git a/apps/backend/routes/schedule.route.ts b/apps/backend/routes/schedule.route.ts index ba379b8..b9584fd 100644 --- a/apps/backend/routes/schedule.route.ts +++ b/apps/backend/routes/schedule.route.ts @@ -1,11 +1,11 @@ -import { Router } from "express"; -import * as scheduleController from "../controllers/schedule.controller.js"; +import { Router } from 'express'; +import * as scheduleController from '../controllers/schedule.controller.js'; export const schedule_route = Router(); -schedule_route.get("/", scheduleController.getSchedule); +schedule_route.get('/', scheduleController.getSchedule); -schedule_route.post("/", scheduleController.addToSchedule); +schedule_route.post('/', scheduleController.addToSchedule); /* schedule_route.delete('/', scheduleController.removeFromSchedule); diff --git a/apps/backend/services/activityTracking.service.ts b/apps/backend/services/activityTracking.service.ts index 86419dc..696969e 100644 --- a/apps/backend/services/activityTracking.service.ts +++ b/apps/backend/services/activityTracking.service.ts @@ -32,10 +32,6 @@ export async function recordActivity(userId: string): Promise { * @returns The user activity record or null if not found */ export async function getActivity(userId: string) { - const result = await db - .select() - .from(user_activity) - .where(eq(user_activity.userId, userId)) - .limit(1); + const result = await db.select().from(user_activity).where(eq(user_activity.userId, userId)).limit(1); return result[0] || null; } diff --git a/apps/backend/services/anonymousDevice.service.ts b/apps/backend/services/anonymousDevice.service.ts index 95583d1..00829d2 100644 --- a/apps/backend/services/anonymousDevice.service.ts +++ b/apps/backend/services/anonymousDevice.service.ts @@ -90,11 +90,7 @@ export const tokenNeedsRefresh = (exp: number): boolean => { * Gets a device by its device ID */ export const getDeviceByDeviceId = async (deviceId: string): Promise => { - const result = await db - .select() - .from(anonymous_devices) - .where(eq(anonymous_devices.deviceId, deviceId)) - .limit(1); + const result = await db.select().from(anonymous_devices).where(eq(anonymous_devices.deviceId, deviceId)).limit(1); return result[0] || null; }; diff --git a/apps/backend/services/artwork/finder.ts b/apps/backend/services/artwork/finder.ts index 7591a5c..ea33d63 100644 --- a/apps/backend/services/artwork/finder.ts +++ b/apps/backend/services/artwork/finder.ts @@ -5,12 +5,7 @@ */ import { ArtworkProvider, discogsProvider } from './providers/index.js'; -import { - ArtworkRequest, - ArtworkResponse, - ArtworkSearchResult, - EnrichedLibraryResult, -} from '../requestLine/types.js'; +import { ArtworkRequest, ArtworkResponse, ArtworkSearchResult, EnrichedLibraryResult } from '../requestLine/types.js'; import { isCompilationArtist } from '../requestLine/matching/index.js'; import { getConfig } from '../requestLine/config.js'; @@ -131,9 +126,7 @@ export async function fetchArtworkForItems( const finder = getArtworkFinder(); const discogsTitlesMap = discogsTitles || new Map(); - const fetchOne = async ( - item: EnrichedLibraryResult - ): Promise => { + const fetchOne = async (item: EnrichedLibraryResult): Promise => { try { // Use Discogs album title if we have it (from compilation search) const album = discogsTitlesMap.get(item.id) || item.title; diff --git a/apps/backend/services/artwork/index.ts b/apps/backend/services/artwork/index.ts index 14d09d4..d1814e8 100644 --- a/apps/backend/services/artwork/index.ts +++ b/apps/backend/services/artwork/index.ts @@ -2,11 +2,6 @@ * Barrel export for artwork services. */ -export { - ArtworkFinder, - getArtworkFinder, - resetArtworkFinder, - fetchArtworkForItems, -} from './finder.js'; +export { ArtworkFinder, getArtworkFinder, resetArtworkFinder, fetchArtworkForItems } from './finder.js'; export type { ArtworkProvider } from './providers/index.js'; export { DiscogsProvider, discogsProvider } from './providers/index.js'; diff --git a/apps/backend/services/artwork/providers/discogs.ts b/apps/backend/services/artwork/providers/discogs.ts index 74142f1..f43323d 100644 --- a/apps/backend/services/artwork/providers/discogs.ts +++ b/apps/backend/services/artwork/providers/discogs.ts @@ -47,12 +47,7 @@ export class DiscogsProvider implements ArtworkProvider { } // Calculate confidence score for this result - const confidence = calculateConfidence( - request.artist, - request.album, - item.artist || '', - item.album || '' - ); + const confidence = calculateConfidence(request.artist, request.album, item.artist || '', item.album || ''); results.push({ artworkUrl: item.artworkUrl, @@ -89,11 +84,7 @@ export class DiscogsProvider implements ArtworkProvider { * * @returns List of [artist, album] tuples for releases containing the track. */ - async searchReleasesByTrack( - track: string, - artist?: string, - limit = 20 - ): Promise> { + async searchReleasesByTrack(track: string, artist?: string, limit = 20): Promise> { if (!isDiscogsAvailable()) { return []; } @@ -105,15 +96,9 @@ export class DiscogsProvider implements ArtworkProvider { for (const releaseInfo of response.releases) { // For Various Artists / compilations, validate the tracklist if (artist && releaseInfo.isCompilation) { - const isValid = await DiscogsService.validateTrackOnRelease( - releaseInfo.releaseId, - track, - artist - ); + const isValid = await DiscogsService.validateTrackOnRelease(releaseInfo.releaseId, track, artist); if (!isValid) { - console.log( - `[DiscogsProvider] Skipping '${releaseInfo.album}' - track/artist not validated on release` - ); + console.log(`[DiscogsProvider] Skipping '${releaseInfo.album}' - track/artist not validated on release`); continue; } } @@ -127,11 +112,7 @@ export class DiscogsProvider implements ArtworkProvider { /** * Validate that a track by an artist exists on a release. */ - async validateTrackOnRelease( - releaseId: number, - track: string, - artist: string - ): Promise { + async validateTrackOnRelease(releaseId: number, track: string, artist: string): Promise { if (!isDiscogsAvailable()) { return false; } diff --git a/apps/backend/services/discogs/cache.ts b/apps/backend/services/discogs/cache.ts index c4697ad..0dc8139 100644 --- a/apps/backend/services/discogs/cache.ts +++ b/apps/backend/services/discogs/cache.ts @@ -112,7 +112,7 @@ export function cached( console.log(`[Discogs Cache] Hit for ${funcName}`); // Add cached flag if result is an object if (typeof cached === 'object' && cached !== null) { - return { ...(cached), cached: true } as T & { cached: boolean }; + return { ...cached, cached: true } as T & { cached: boolean }; } return cached as T & { cached?: boolean }; } diff --git a/apps/backend/services/discogs/discogs.service.ts b/apps/backend/services/discogs/discogs.service.ts index 3937e98..72a33dc 100644 --- a/apps/backend/services/discogs/discogs.service.ts +++ b/apps/backend/services/discogs/discogs.service.ts @@ -94,11 +94,7 @@ class DiscogsServiceClass { /** * Search for ALL releases containing a track. */ - async searchReleasesByTrack( - track: string, - artist?: string, - limit = 20 - ): Promise { + async searchReleasesByTrack(track: string, artist?: string, limit = 20): Promise { const cache = getTrackCache(); const cacheKey = makeCacheKey('searchReleasesByTrack', [track, artist, limit]); @@ -192,10 +188,7 @@ class DiscogsServiceClass { /** * Process a single search result into a DiscogsReleaseInfo. */ - private processSearchResult( - result: RawDiscogsSearchResult, - seenAlbums: Set - ): DiscogsReleaseInfo | null { + private processSearchResult(result: RawDiscogsSearchResult, seenAlbums: Set): DiscogsReleaseInfo | null { const { artist, album } = parseTitle(result.title); if (!album) { @@ -303,10 +296,7 @@ class DiscogsServiceClass { let response = await discogsClient.get('/database/search', { params }); // If strict search returned nothing, try fuzzy query - if ( - (!response.data.results || response.data.results.length === 0) && - (request.artist || request.album) - ) { + if ((!response.data.results || response.data.results.length === 0) && (request.artist || request.album)) { const queryParts: string[] = []; if (request.artist) queryParts.push(request.artist); if (request.album) queryParts.push(request.album); @@ -332,12 +322,7 @@ class DiscogsServiceClass { const { artist, album } = parseTitle(item.title); - const confidence = calculateConfidence( - request.artist, - request.album, - artist, - album - ); + const confidence = calculateConfidence(request.artist, request.album, artist, album); const releaseUrl = `https://www.discogs.com/release/${item.id}`; @@ -375,10 +360,7 @@ class DiscogsServiceClass { /** * Build search params using Discogs-specific fields. */ - private buildSearchParams( - request: DiscogsSearchRequest, - limit: number - ): Record { + private buildSearchParams(request: DiscogsSearchRequest, limit: number): Record { const params: Record = { type: 'release', per_page: limit, @@ -399,11 +381,7 @@ class DiscogsServiceClass { /** * Validate that a track by an artist exists on a release. */ - async validateTrackOnRelease( - releaseId: number, - track: string, - artist: string - ): Promise { + async validateTrackOnRelease(releaseId: number, track: string, artist: string): Promise { const release = await this.getRelease(releaseId); if (!release) { return false; @@ -425,9 +403,7 @@ class DiscogsServiceClass { releaseArtist = releaseArtist.split('(')[0].trim(); if (artistLower.includes(releaseArtist) || releaseArtist.includes(artistLower)) { - console.log( - `[Discogs] Validated: '${track}' by '${artist}' found on release ${releaseId}` - ); + console.log(`[Discogs] Validated: '${track}' by '${artist}' found on release ${releaseId}`); return true; } } @@ -439,10 +415,7 @@ class DiscogsServiceClass { /** * Search for releases by artist (for song-as-artist fallback). */ - async searchReleasesByArtist( - artist: string, - limit = 10 - ): Promise> { + async searchReleasesByArtist(artist: string, limit = 10): Promise> { const params = { type: 'release', artist: artist, diff --git a/apps/backend/services/discogs/index.ts b/apps/backend/services/discogs/index.ts index 3c1650e..aa277a5 100644 --- a/apps/backend/services/discogs/index.ts +++ b/apps/backend/services/discogs/index.ts @@ -4,11 +4,5 @@ export { DiscogsService, isDiscogsAvailable } from './discogs.service.js'; export { discogsClient, parseTitle, resetDiscogsClient } from './client.js'; -export { - getTrackCache, - getReleaseCache, - getSearchCache, - clearAllCaches, - resetAllCaches, -} from './cache.js'; +export { getTrackCache, getReleaseCache, getSearchCache, clearAllCaches, resetAllCaches } from './cache.js'; export * from './types.js'; diff --git a/apps/backend/services/djs.service.ts b/apps/backend/services/djs.service.ts index bd4e700..f25e317 100644 --- a/apps/backend/services/djs.service.ts +++ b/apps/backend/services/djs.service.ts @@ -13,7 +13,7 @@ import { shows, specialty_shows, user, -} from "@wxyc/database"; +} from '@wxyc/database'; import { and, eq, isNull } from 'drizzle-orm'; export const addToBin = async (bin_entry: NewBinEntry): Promise => { diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index 0dd673d..2170730 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -17,7 +17,7 @@ import { specialty_shows, album_metadata, artist_metadata, -} from "@wxyc/database"; +} from '@wxyc/database'; import { IFSEntry, ShowInfo, UpdateRequestBody } from '../controllers/flowsheet.controller.js'; import { PgSelectQueryBuilder, QueryBuilder } from 'drizzle-orm/pg-core'; @@ -294,9 +294,12 @@ 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', - })}`, + message: `Start of Show: DJ ${dj_info.djName || dj_info.name} joined the set at ${new Date().toLocaleString( + 'en-US', + { + timeZone: 'America/New_York', + } + )}`, }); updateLastModified(); diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index d9c0a84..b6c82fb 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -14,7 +14,7 @@ import { library_artist_view, rotation, LibraryArtistViewEntry, -} from "@wxyc/database"; +} from '@wxyc/database'; import { LibraryResult, EnrichedLibraryResult, enrichLibraryResult } from './requestLine/types.js'; import { extractSignificantWords } from './requestLine/matching/index.js'; @@ -322,10 +322,7 @@ export async function searchLibrary( * @param threshold - Minimum similarity score (0.0 to 1.0) to accept * @returns Corrected artist name if a good match is found, null otherwise */ -export async function findSimilarArtist( - artistName: string, - threshold = 0.85 -): Promise { +export async function findSimilarArtist(artistName: string, threshold = 0.85): Promise { // Use pg_trgm similarity function to find close matches const query = sql` SELECT DISTINCT artist_name, @@ -362,10 +359,7 @@ export async function findSimilarArtist( * @param limit - Maximum results to return * @returns Array of enriched library results */ -export async function searchAlbumsByTitle( - albumTitle: string, - limit = 5 -): Promise { +export async function searchAlbumsByTitle(albumTitle: string, limit = 5): Promise { const query = sql` SELECT *, similarity(${library_artist_view.album_title}, ${albumTitle}) as sim @@ -383,9 +377,7 @@ export async function searchAlbumsByTitle( const words = extractSignificantWords(albumTitle); if (words.length > 0) { const significantWords = words.slice(0, 4); // Use up to 4 significant words - const likeConditions = significantWords - .map((w) => `album_title ILIKE '%${w}%'`) - .join(' AND '); + const likeConditions = significantWords.map((w) => `album_title ILIKE '%${w}%'`).join(' AND '); const fallbackQuery = sql.raw(` SELECT * FROM wxyc_schema.library_artist_view @@ -410,10 +402,7 @@ export async function searchAlbumsByTitle( * @param limit - Maximum results to return * @returns Array of enriched library results */ -export async function searchByArtist( - artistName: string, - limit = 5 -): Promise { +export async function searchByArtist(artistName: string, limit = 5): Promise { const query = sql` SELECT *, similarity(${library_artist_view.artist_name}, ${artistName}) as sim @@ -457,9 +446,7 @@ export function filterResultsByArtist( }); if (filtered.length < results.length) { - console.log( - `[Library] Filtered ${results.length} results to ${filtered.length} matching artist '${artist}'` - ); + console.log(`[Library] Filtered ${results.length} results to ${filtered.length} matching artist '${artist}'`); } return filtered; diff --git a/apps/backend/services/metadata/metadata.cache.ts b/apps/backend/services/metadata/metadata.cache.ts index fc28e30..fdfb175 100644 --- a/apps/backend/services/metadata/metadata.cache.ts +++ b/apps/backend/services/metadata/metadata.cache.ts @@ -28,25 +28,14 @@ export function generateArtistCacheKey(artistName: string): string { /** * Get album metadata by album_id or cache_key */ -export async function getAlbumMetadata( - albumId: number | null, - cacheKey?: string -): Promise { +export async function getAlbumMetadata(albumId: number | null, cacheKey?: string): Promise { let row: AlbumMetadataRow | undefined; if (albumId) { - const result = await db - .select() - .from(album_metadata) - .where(eq(album_metadata.album_id, albumId)) - .limit(1); + const result = await db.select().from(album_metadata).where(eq(album_metadata.album_id, albumId)).limit(1); row = result[0]; } else if (cacheKey) { - const result = await db - .select() - .from(album_metadata) - .where(eq(album_metadata.cache_key, cacheKey)) - .limit(1); + const result = await db.select().from(album_metadata).where(eq(album_metadata.cache_key, cacheKey)).limit(1); row = result[0]; } @@ -117,10 +106,7 @@ export async function setAlbumMetadata( /** * Check if album metadata exists */ -export async function albumMetadataExists( - albumId: number | null, - cacheKey?: string -): Promise { +export async function albumMetadataExists(albumId: number | null, cacheKey?: string): Promise { if (albumId) { const result = await db .select({ id: album_metadata.id }) @@ -149,18 +135,10 @@ export async function getArtistMetadata( let row: ArtistMetadataRow | undefined; if (artistId) { - const result = await db - .select() - .from(artist_metadata) - .where(eq(artist_metadata.artist_id, artistId)) - .limit(1); + const result = await db.select().from(artist_metadata).where(eq(artist_metadata.artist_id, artistId)).limit(1); row = result[0]; } else if (cacheKey) { - const result = await db - .select() - .from(artist_metadata) - .where(eq(artist_metadata.cache_key, cacheKey)) - .limit(1); + const result = await db.select().from(artist_metadata).where(eq(artist_metadata.cache_key, cacheKey)).limit(1); row = result[0]; } @@ -223,10 +201,7 @@ export async function setArtistMetadata( /** * Check if artist metadata exists */ -export async function artistMetadataExists( - artistId: number | null, - cacheKey?: string -): Promise { +export async function artistMetadataExists(artistId: number | null, cacheKey?: string): Promise { if (artistId) { const result = await db .select({ id: artist_metadata.id }) diff --git a/apps/backend/services/metadata/metadata.service.ts b/apps/backend/services/metadata/metadata.service.ts index b2a9c97..0c25e38 100644 --- a/apps/backend/services/metadata/metadata.service.ts +++ b/apps/backend/services/metadata/metadata.service.ts @@ -1,12 +1,7 @@ /** * Metadata Service - Orchestrates fetching and storing metadata from external APIs */ -import { - MetadataRequest, - AlbumMetadataResult, - ArtistMetadataResult, - FlowsheetMetadata, -} from './metadata.types.js'; +import { MetadataRequest, AlbumMetadataResult, ArtistMetadataResult, FlowsheetMetadata } from './metadata.types.js'; import { setAlbumMetadata, setArtistMetadata, @@ -27,9 +22,7 @@ const searchUrls = new SearchUrlProvider(); /** * Fetch and store metadata for a single entry (called on insert) */ -export async function fetchAndCacheMetadata( - request: MetadataRequest -): Promise { +export async function fetchAndCacheMetadata(request: MetadataRequest): Promise { const result: FlowsheetMetadata = {}; try { @@ -43,12 +36,7 @@ export async function fetchAndCacheMetadata( ? null : generateAlbumCacheKey(request.artistName, request.albumTitle || request.trackTitle); - await setAlbumMetadata( - request.albumId || null, - cacheKey, - albumMetadata, - request.rotationId != null - ); + await setAlbumMetadata(request.albumId || null, cacheKey, albumMetadata, request.rotationId != null); } // Fetch artist metadata diff --git a/apps/backend/services/metadata/providers/apple.provider.ts b/apps/backend/services/metadata/providers/apple.provider.ts index cb0af61..5c8ab1e 100644 --- a/apps/backend/services/metadata/providers/apple.provider.ts +++ b/apps/backend/services/metadata/providers/apple.provider.ts @@ -12,9 +12,7 @@ export class AppleMusicProvider { async searchAlbum(artistName: string, albumTitle: string): Promise { try { const query = encodeURIComponent(`${artistName} ${albumTitle}`); - const response = await fetch( - `${ITUNES_API_BASE}/search?term=${query}&entity=album&limit=1` - ); + const response = await fetch(`${ITUNES_API_BASE}/search?term=${query}&entity=album&limit=1`); if (!response.ok) { console.error(`[AppleMusicProvider] Album search failed: ${response.status}`); @@ -40,9 +38,7 @@ export class AppleMusicProvider { async searchTrack(artistName: string, trackTitle: string): Promise { try { const query = encodeURIComponent(`${artistName} ${trackTitle}`); - const response = await fetch( - `${ITUNES_API_BASE}/search?term=${query}&entity=song&limit=1` - ); + const response = await fetch(`${ITUNES_API_BASE}/search?term=${query}&entity=song&limit=1`); if (!response.ok) { console.error(`[AppleMusicProvider] Track search failed: ${response.status}`); @@ -66,11 +62,7 @@ export class AppleMusicProvider { /** * Get Apple Music URL for an album (preferred) or track */ - async getAppleMusicUrl( - artistName: string, - albumTitle?: string, - trackTitle?: string - ): Promise { + async getAppleMusicUrl(artistName: string, albumTitle?: string, trackTitle?: string): Promise { // Try album search first if we have an album title if (albumTitle) { const albumUrl = await this.searchAlbum(artistName, albumTitle); diff --git a/apps/backend/services/metadata/providers/discogs.provider.ts b/apps/backend/services/metadata/providers/discogs.provider.ts index da15642..1012d08 100644 --- a/apps/backend/services/metadata/providers/discogs.provider.ts +++ b/apps/backend/services/metadata/providers/discogs.provider.ts @@ -75,10 +75,7 @@ export class DiscogsProvider { /** * Search for a release by artist and album title */ - async searchRelease( - artistName: string, - albumTitle: string - ): Promise { + async searchRelease(artistName: string, albumTitle: string): Promise { if (!this.apiKey) return null; // Handle self-titled albums @@ -95,9 +92,7 @@ export class DiscogsProvider { }); try { - const response = await this.throttledFetch( - `${DISCOGS_API_BASE}/database/search?${params}` - ); + const response = await this.throttledFetch(`${DISCOGS_API_BASE}/database/search?${params}`); if (!response.ok) { console.error(`[DiscogsProvider] Search failed: ${response.status}`); @@ -191,10 +186,7 @@ export class DiscogsProvider { /** * Fetch full album metadata for a given artist and album */ - async fetchAlbumMetadata( - artistName: string, - albumTitle: string - ): Promise { + async fetchAlbumMetadata(artistName: string, albumTitle: string): Promise { const searchResult = await this.searchRelease(artistName, albumTitle); if (!searchResult) return null; @@ -264,9 +256,7 @@ export class DiscogsProvider { }); try { - const searchResponse = await this.throttledFetch( - `${DISCOGS_API_BASE}/database/search?${params}` - ); + const searchResponse = await this.throttledFetch(`${DISCOGS_API_BASE}/database/search?${params}`); if (!searchResponse.ok) { return null; diff --git a/apps/backend/services/metadata/providers/search-urls.provider.ts b/apps/backend/services/metadata/providers/search-urls.provider.ts index 35d199a..dfea3c5 100644 --- a/apps/backend/services/metadata/providers/search-urls.provider.ts +++ b/apps/backend/services/metadata/providers/search-urls.provider.ts @@ -8,11 +8,7 @@ export class SearchUrlProvider { * Get YouTube Music search URL */ getYoutubeMusicUrl(artistName: string, trackTitle?: string, albumTitle?: string): string { - const query = trackTitle - ? `${artistName} ${trackTitle}` - : albumTitle - ? `${artistName} ${albumTitle}` - : artistName; + const query = trackTitle ? `${artistName} ${trackTitle}` : albumTitle ? `${artistName} ${albumTitle}` : artistName; return `https://music.youtube.com/search?q=${encodeURIComponent(query)}`; } diff --git a/apps/backend/services/metadata/providers/spotify.provider.ts b/apps/backend/services/metadata/providers/spotify.provider.ts index e0dbdbe..0f9d328 100644 --- a/apps/backend/services/metadata/providers/spotify.provider.ts +++ b/apps/backend/services/metadata/providers/spotify.provider.ts @@ -163,11 +163,7 @@ export class SpotifyProvider { /** * Get Spotify URL for an album (preferred) or track */ - async getSpotifyUrl( - artistName: string, - albumTitle?: string, - trackTitle?: string - ): Promise { + async getSpotifyUrl(artistName: string, albumTitle?: string, trackTitle?: string): Promise { // Try album search first if we have an album title if (albumTitle) { const albumUrl = await this.searchAlbum(artistName, albumTitle); diff --git a/apps/backend/services/requestLine/config.ts b/apps/backend/services/requestLine/config.ts index a428b22..142dec4 100644 --- a/apps/backend/services/requestLine/config.ts +++ b/apps/backend/services/requestLine/config.ts @@ -67,7 +67,9 @@ export function validateConfig(config: RequestLineConfig): string[] { // Discogs is optional but warn if not configured if (!config.discogsApiKey || !config.discogsApiSecret) { - console.warn('[RequestLine Config] DISCOGS_API_KEY or DISCOGS_API_SECRET not set - artwork lookup and compilation search will be disabled'); + console.warn( + '[RequestLine Config] DISCOGS_API_KEY or DISCOGS_API_SECRET not set - artwork lookup and compilation search will be disabled' + ); } return errors; diff --git a/apps/backend/services/requestLine/matching/compilation.ts b/apps/backend/services/requestLine/matching/compilation.ts index 22564bc..0ce0106 100644 --- a/apps/backend/services/requestLine/matching/compilation.ts +++ b/apps/backend/services/requestLine/matching/compilation.ts @@ -7,13 +7,7 @@ /** * Keywords indicating a compilation/soundtrack album (case-insensitive substring match). */ -export const COMPILATION_KEYWORDS = new Set([ - 'various', - 'soundtrack', - 'compilation', - 'v/a', - 'v.a.', -]); +export const COMPILATION_KEYWORDS = new Set(['various', 'soundtrack', 'compilation', 'v/a', 'v.a.']); /** * Check if an artist name indicates a compilation/soundtrack album. diff --git a/apps/backend/services/requestLine/requestLine.enhanced.service.ts b/apps/backend/services/requestLine/requestLine.enhanced.service.ts index 2a1fa9b..3bc8468 100644 --- a/apps/backend/services/requestLine/requestLine.enhanced.service.ts +++ b/apps/backend/services/requestLine/requestLine.enhanced.service.ts @@ -38,22 +38,16 @@ import { MAX_SEARCH_RESULTS } from './matching/index.js'; * * Searches Discogs for ALL releases containing the track, not just the first one. */ -async function resolveAlbumsForTrack( - parsed: ParsedRequest -): Promise<{ albums: string[]; songNotFound: boolean }> { +async function resolveAlbumsForTrack(parsed: ParsedRequest): Promise<{ albums: string[]; songNotFound: boolean }> { // Check if album is missing or if album == artist (parser error) const albumIsMissing = !parsed.album; const albumIsArtist = - parsed.album && - parsed.artist && - parsed.album.toLowerCase().trim() === parsed.artist.toLowerCase().trim(); + parsed.album && parsed.artist && parsed.album.toLowerCase().trim() === parsed.artist.toLowerCase().trim(); // Only do track lookup if we have an artist if (parsed.song && parsed.artist && (albumIsMissing || albumIsArtist)) { if (albumIsArtist) { - console.log( - `[RequestLine] Album '${parsed.album}' appears to be artist name, looking up albums` - ); + console.log(`[RequestLine] Album '${parsed.album}' appears to be artist name, looking up albums`); } if (!isDiscogsAvailable()) { @@ -63,11 +57,7 @@ async function resolveAlbumsForTrack( try { // Get ALL releases containing this track - const releases = await discogsProvider.searchReleasesByTrack( - parsed.song, - parsed.artist, - 10 - ); + const releases = await discogsProvider.searchReleasesByTrack(parsed.song, parsed.artist, 10); if (releases.length > 0) { // Extract unique album names, filtering to releases by this artist @@ -84,9 +74,7 @@ async function resolveAlbumsForTrack( } if (albums.length > 0) { - console.log( - `[RequestLine] Found ${albums.length} albums for song '${parsed.song}': ${albums.join(', ')}` - ); + console.log(`[RequestLine] Found ${albums.length} albums for song '${parsed.song}': ${albums.join(', ')}`); return { albums, songNotFound: false }; } } @@ -165,9 +153,7 @@ async function postResultsToSlack( * * This is the main entry point for the enhanced request line service. */ -export async function processRequest( - body: RequestLineRequestBody -): Promise { +export async function processRequest(body: RequestLineRequestBody): Promise { const config = getConfig(); const message = body.message.trim(); @@ -187,9 +173,7 @@ export async function processRequest( if (!body.skipParsing && isParserAvailable()) { try { parsed = await parseRequest(message); - console.log( - `[RequestLine] Parsed request: is_request=${parsed.isRequest}, type=${parsed.messageType}` - ); + console.log(`[RequestLine] Parsed request: is_request=${parsed.isRequest}, type=${parsed.messageType}`); } catch (error) { console.error('[RequestLine] Parsing failed:', error); // Per the plan: "Requests fail if Groq is unavailable" @@ -224,14 +208,15 @@ export async function processRequest( } // Step 2: Look up albums from Discogs if we have a song but no album - const { albums: albumsForSearch, songNotFound: initialSongNotFound } = - await resolveAlbumsForTrack(parsed); + const { albums: albumsForSearch, songNotFound: initialSongNotFound } = await resolveAlbumsForTrack(parsed); songNotFound = initialSongNotFound; // Step 3: Execute search strategy pipeline if (config.enableLibrarySearch) { const searchState = await executeSearchPipeline(parsed, message, { - discogsService: isDiscogsAvailable() ? DiscogsService as unknown as PipelineOptions['discogsService'] : undefined, + discogsService: isDiscogsAvailable() + ? (DiscogsService as unknown as PipelineOptions['discogsService']) + : undefined, albumsForSearch, }); @@ -258,12 +243,7 @@ export async function processRequest( let slackResult: SlackPostResult = { success: true, message: 'Slack posting skipped' }; if (!body.skipSlack) { - const context = buildContextMessage( - parsed, - foundOnCompilation, - songNotFound, - libraryResults.length > 0 - ); + const context = buildContextMessage(parsed, foundOnCompilation, songNotFound, libraryResults.length > 0); try { slackResult = await postResultsToSlack(message, parsed, itemsWithArtwork, context); @@ -277,8 +257,7 @@ export async function processRequest( } // Extract main artwork from first result - const artwork = - itemsWithArtwork.find(([_, art]) => art !== null)?.[1] || null; + const artwork = itemsWithArtwork.find(([_, art]) => art !== null)?.[1] || null; return { success: true, diff --git a/apps/backend/services/requestLine/search/pipeline.ts b/apps/backend/services/requestLine/search/pipeline.ts index b6e951a..719d92e 100644 --- a/apps/backend/services/requestLine/search/pipeline.ts +++ b/apps/backend/services/requestLine/search/pipeline.ts @@ -25,10 +25,7 @@ interface DiscogsService { artist?: string, limit?: number ) => Promise>; - searchReleasesByArtist: ( - artist: string, - limit?: number - ) => Promise>; + searchReleasesByArtist: (artist: string, limit?: number) => Promise>; validateTrackOnRelease: (releaseId: number, track: string, artist: string) => Promise; } diff --git a/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts b/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts index 67b2437..e3a29a1 100644 --- a/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts +++ b/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts @@ -16,11 +16,7 @@ import { MAX_SEARCH_RESULTS } from '../../matching/index.js'; /** * Check if this strategy should run. */ -export function shouldRunArtistPlusAlbum( - parsed: ParsedRequest, - state: SearchState, - _rawMessage: string -): boolean { +export function shouldRunArtistPlusAlbum(parsed: ParsedRequest, state: SearchState, _rawMessage: string): boolean { return !!(parsed.artist && (state.albumsForSearch.length > 0 || parsed.song)); } diff --git a/apps/backend/services/requestLine/search/strategies/songAsArtist.ts b/apps/backend/services/requestLine/search/strategies/songAsArtist.ts index 8d087df..d0b9a50 100644 --- a/apps/backend/services/requestLine/search/strategies/songAsArtist.ts +++ b/apps/backend/services/requestLine/search/strategies/songAsArtist.ts @@ -14,20 +14,13 @@ import { isCompilationArtist, MAX_SEARCH_RESULTS } from '../../matching/index.js // Forward declaration - will be imported when Discogs service is ready type DiscogsService = { - searchReleasesByArtist: ( - artist: string, - limit?: number - ) => Promise>; + searchReleasesByArtist: (artist: string, limit?: number) => Promise>; }; /** * Check if this strategy should run. */ -export function shouldRunSongAsArtist( - parsed: ParsedRequest, - state: SearchState, - _rawMessage: string -): boolean { +export function shouldRunSongAsArtist(parsed: ParsedRequest, state: SearchState, _rawMessage: string): boolean { // Only run if no results AND parsed song but no artist return state.results.length === 0 && !!parsed.song && !parsed.artist; } @@ -91,15 +84,10 @@ export async function executeSongAsArtist( // Accept if it's the actual artist or a compilation const itemArtist = (item.artist || '').toLowerCase(); - if ( - itemArtist.startsWith(songAsArtist.toLowerCase()) || - isCompilationArtist(item.artist) - ) { + if (itemArtist.startsWith(songAsArtist.toLowerCase()) || isCompilationArtist(item.artist)) { crossRefResults.push(item); seenIds.add(item.id); - console.log( - `[Search] Found '${item.artist} - ${item.title}' via Discogs cross-reference` - ); + console.log(`[Search] Found '${item.artist} - ${item.title}' via Discogs cross-reference`); } } @@ -109,9 +97,7 @@ export async function executeSongAsArtist( } if (crossRefResults.length > 0) { - console.log( - `[Search] Found ${crossRefResults.length} results via Discogs cross-reference for '${songAsArtist}'` - ); + console.log(`[Search] Found ${crossRefResults.length} results via Discogs cross-reference for '${songAsArtist}'`); } return crossRefResults.slice(0, MAX_SEARCH_RESULTS); diff --git a/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts b/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts index b0f4603..99c139a 100644 --- a/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts +++ b/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts @@ -30,9 +30,7 @@ export function shouldRunSwappedInterpretation( /** * Execute the swapped interpretation search strategy. */ -export async function executeSwappedInterpretation( - rawMessage: string -): Promise { +export async function executeSwappedInterpretation(rawMessage: string): Promise { const parts = detectAmbiguousFormat(rawMessage); if (!parts) { return []; diff --git a/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts b/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts index 68b7a5e..e28d8da 100644 --- a/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts +++ b/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts @@ -10,12 +10,7 @@ import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; import { searchLibrary, searchAlbumsByTitle, filterResultsByArtist } from '../../../library.service.js'; -import { - extractSignificantWords, - isCompilationArtist, - STOPWORDS, - MAX_SEARCH_RESULTS, -} from '../../matching/index.js'; +import { extractSignificantWords, isCompilationArtist, STOPWORDS, MAX_SEARCH_RESULTS } from '../../matching/index.js'; // Forward declaration - will be imported when Discogs service is ready type DiscogsService = { @@ -30,11 +25,7 @@ type DiscogsService = { /** * Check if this strategy should run. */ -export function shouldRunTrackOnCompilation( - parsed: ParsedRequest, - state: SearchState, - _rawMessage: string -): boolean { +export function shouldRunTrackOnCompilation(parsed: ParsedRequest, state: SearchState, _rawMessage: string): boolean { // Only run if song not found AND we have both artist and song return state.songNotFound && !!parsed.artist && !!parsed.song; } @@ -90,9 +81,7 @@ export async function executeTrackOnCompilation( } if (filtered.length > 0) { - console.log( - `[Search] Found ${filtered.length} matches via keyword search (after artist filter)` - ); + console.log(`[Search] Found ${filtered.length} matches via keyword search (after artist filter)`); // Don't add to results yet - prefer Discogs results which know actual track listings keywordMatches = filtered; } @@ -106,20 +95,13 @@ export async function executeTrackOnCompilation( // If Discogs service is available, use it for more accurate results if (discogsService) { try { - const releases = await discogsService.searchReleasesByTrack( - parsed.song, - parsed.artist, - 20 - ); + const releases = await discogsService.searchReleasesByTrack(parsed.song, parsed.artist, 20); console.log(`[Search] Found ${releases.length} releases with '${parsed.song}' on Discogs`); // Check each release against our library for (const release of releases) { // Skip if the "album" is just the artist name (Discogs artifact) - if ( - parsed.artist && - release.album.toLowerCase().trim() === parsed.artist.toLowerCase().trim() - ) { + if (parsed.artist && release.album.toLowerCase().trim() === parsed.artist.toLowerCase().trim()) { console.log(`[Search] Skipping '${release.album}' - appears to be artist name, not album`); continue; } @@ -131,15 +113,9 @@ export async function executeTrackOnCompilation( // For Various Artists / compilations, validate the tracklist if (release.isCompilation) { - const isValid = await discogsService.validateTrackOnRelease( - release.releaseId, - parsed.song, - parsed.artist - ); + const isValid = await discogsService.validateTrackOnRelease(release.releaseId, parsed.song, parsed.artist); if (!isValid) { - console.log( - `[Search] Skipping '${release.album}' - track/artist not validated on release` - ); + console.log(`[Search] Skipping '${release.album}' - track/artist not validated on release`); continue; } } diff --git a/apps/backend/services/schedule.service.ts b/apps/backend/services/schedule.service.ts index c076a0b..f9d3e4d 100644 --- a/apps/backend/services/schedule.service.ts +++ b/apps/backend/services/schedule.service.ts @@ -1,4 +1,4 @@ -import { db, NewShift, schedule } from "@wxyc/database"; +import { db, NewShift, schedule } from '@wxyc/database'; export const getSchedule = async () => { const response = await db.select().from(schedule); diff --git a/apps/backend/services/slack/builder.ts b/apps/backend/services/slack/builder.ts index 95c0f1b..faa2c1c 100644 --- a/apps/backend/services/slack/builder.ts +++ b/apps/backend/services/slack/builder.ts @@ -76,9 +76,7 @@ export function buildSlackBlocks( ]; if (artwork && artwork.releaseUrl) { - textLines.push( - `<${artwork.releaseUrl}|Discogs> | <${item.libraryUrl}|WXYC>` - ); + textLines.push(`<${artwork.releaseUrl}|Discogs> | <${item.libraryUrl}|WXYC>`); } else { textLines.push(`<${item.libraryUrl}|WXYC Library>`); } @@ -111,10 +109,7 @@ export function buildSlackBlocks( * @param message - Original request message * @param context - Optional context message */ -export function buildSimpleSlackBlocks( - message: string, - context?: string -): SlackBlock[] { +export function buildSimpleSlackBlocks(message: string, context?: string): SlackBlock[] { const blocks: SlackBlock[] = [ { type: 'section', @@ -145,8 +140,5 @@ export function buildSimpleSlackBlocks( */ function escapeSlackText(text: string): string { // Escape &, <, > which have special meaning in Slack - return text - .replace(/&/g, '&') - .replace(//g, '>'); + return text.replace(/&/g, '&').replace(//g, '>'); } diff --git a/apps/backend/services/slack/index.ts b/apps/backend/services/slack/index.ts index 00d97e9..30dfb06 100644 --- a/apps/backend/services/slack/index.ts +++ b/apps/backend/services/slack/index.ts @@ -2,14 +2,5 @@ * Barrel export for Slack services. */ -export { - buildSlackBlocks, - buildSimpleSlackBlocks, - type SlackBlock, -} from './builder.js'; -export { - postTextToSlack, - postBlocksToSlack, - isSlackConfigured, - type SlackPostResult, -} from './slack.service.js'; +export { buildSlackBlocks, buildSimpleSlackBlocks, type SlackBlock } from './builder.js'; +export { postTextToSlack, postBlocksToSlack, isSlackConfigured, type SlackPostResult } from './slack.service.js'; diff --git a/apps/backend/services/slack/slack.service.ts b/apps/backend/services/slack/slack.service.ts index 105fec2..6ead7b4 100644 --- a/apps/backend/services/slack/slack.service.ts +++ b/apps/backend/services/slack/slack.service.ts @@ -40,10 +40,7 @@ export async function postTextToSlack(message: string): Promise * @param blocks - Slack block array * @param fallbackText - Optional fallback text for notifications */ -export async function postBlocksToSlack( - blocks: SlackBlock[], - fallbackText?: string -): Promise { +export async function postBlocksToSlack(blocks: SlackBlock[], fallbackText?: string): Promise { const payload: { blocks: SlackBlock[]; text?: string } = { blocks }; // Add fallback text for notifications (shown in push notifications, etc.) @@ -114,8 +111,5 @@ async function postToSlack(payload: object): Promise { * Check if Slack is configured. */ export function isSlackConfigured(): boolean { - return !!( - process.env.SLACK_WXYC_REQUESTS_WEBHOOK && - process.env.USE_MOCK_SERVICES !== 'true' - ); + return !!(process.env.SLACK_WXYC_REQUESTS_WEBHOOK && process.env.USE_MOCK_SERVICES !== 'true'); } diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index a63fa97..98cec53 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": ["../../tsconfig.base.json"], - "references": [{ "path": "../../shared/database" }, { "path": "../../shared/authentication" }], - "include": ["."], - "compilerOptions": { - "module": "esnext", - "moduleResolution": "bundler", - "paths": { - "@/*": ["./*"] - } + "extends": ["../../tsconfig.base.json"], + "references": [{ "path": "../../shared/database" }, { "path": "../../shared/authentication" }], + "include": ["."], + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@/*": ["./*"] } -} \ No newline at end of file + } +} diff --git a/apps/backend/tsup.config.ts b/apps/backend/tsup.config.ts index 355ab8d..21421d2 100644 --- a/apps/backend/tsup.config.ts +++ b/apps/backend/tsup.config.ts @@ -8,7 +8,7 @@ const __dirname = dirname(__filename); export default defineConfig((options) => ({ entry: ['app.ts'], - format: ["cjs"], + format: ['cjs'], outDir: 'dist', clean: true, onSuccess: options.watch ? 'node ./dist/app.js' : undefined, diff --git a/apps/backend/utils/serverEvents.ts b/apps/backend/utils/serverEvents.ts index d234a14..db80e02 100644 --- a/apps/backend/utils/serverEvents.ts +++ b/apps/backend/utils/serverEvents.ts @@ -35,7 +35,7 @@ export const MirrorEvents = { syncComplete: 'sync-complete', syncRetry: 'sync-retry', syncError: 'sync-error', -} +}; export type EventClient = { id: string; //uuid diff --git a/dev_env/setup-e2e-test-users.ts b/dev_env/setup-e2e-test-users.ts index 25d5e80..463c49f 100644 --- a/dev_env/setup-e2e-test-users.ts +++ b/dev_env/setup-e2e-test-users.ts @@ -57,14 +57,13 @@ async function setUpTestUsers() { try { // Use custom password if specified, otherwise default test password const passwordHash = user.password - ? (user.password === TEMP_PASSWORD ? hashedTempPassword : await context.password.hash(user.password)) + ? user.password === TEMP_PASSWORD + ? hashedTempPassword + : await context.password.hash(user.password) : hashedTestPassword; // Update the account with the password hash - const result = await db - .update(account) - .set({ password: passwordHash }) - .where(eq(account.userId, user.id)); + const result = await db.update(account).set({ password: passwordHash }).where(eq(account.userId, user.id)); const passwordType = user.password ? user.password : TEST_PASSWORD; console.log(`[+] Set password for ${user.username} (${passwordType})`); diff --git a/docs/metadata-service/README.md b/docs/metadata-service/README.md index 00b1602..dc91a54 100644 --- a/docs/metadata-service/README.md +++ b/docs/metadata-service/README.md @@ -19,14 +19,18 @@ This document describes the metadata service integration that moves metadata fet ## Overview ### Problem + The iOS client was responsible for fetching metadata (album art, streaming links, artist bios) from multiple external APIs (Discogs, Spotify, Apple Music). This created: + - Redundant API calls across multiple clients - Inconsistent caching strategies - Poor offline experience - API rate limiting issues ### Solution + Move metadata fetching to the backend with: + - **Database-backed storage** for persistent metadata - **Fire-and-forget fetching** for non-blocking metadata retrieval - **LEFT JOINs** to include metadata in flowsheet responses automatically @@ -41,19 +45,20 @@ The overall system architecture showing how components interact: ![Architecture Diagram](./architecture.svg) > **Note:** Diagram source files are in `diagrams/*.mmd` (Mermaid format). Regenerate SVGs with: +> > ```bash > npx @mermaid-js/mermaid-cli -i diagrams/architecture.mmd -o architecture.svg -b transparent > ``` ### Key Components -| Component | Responsibility | -|-----------|---------------| -| **Flowsheet Controller** | Handles HTTP requests, triggers async metadata fetch | -| **Flowsheet Service** | Database queries with metadata JOINs, last-modified tracking | -| **Metadata Service** | Coordinates external API calls, stores results | -| **PostgreSQL** | Persistent storage for flowsheet and metadata tables | -| **External APIs** | Discogs, Spotify, Apple Music for metadata | +| Component | Responsibility | +| ------------------------ | ------------------------------------------------------------ | +| **Flowsheet Controller** | Handles HTTP requests, triggers async metadata fetch | +| **Flowsheet Service** | Database queries with metadata JOINs, last-modified tracking | +| **Metadata Service** | Coordinates external API calls, stores results | +| **PostgreSQL** | Persistent storage for flowsheet and metadata tables | +| **External APIs** | Discogs, Spotify, Apple Music for metadata | --- @@ -64,6 +69,7 @@ The flowsheet service tracks when the flowsheet was last modified to support con ### Modification Triggers The `lastModifiedAt` timestamp is updated when: + - Track is added (`addTrack`) - Track is deleted (`removeTrack`) - Track is updated (`updateEntry`) @@ -90,10 +96,10 @@ The flowsheet endpoints support conditional requests via the `Last-Modified` hea ### Supported Endpoints -| Endpoint | Support | -|----------|---------| -| `GET /flowsheet` | Yes | -| `GET /flowsheet/latest` | Yes | +| Endpoint | Support | +| ----------------------- | ------- | +| `GET /flowsheet` | Yes | +| `GET /flowsheet/latest` | Yes | ### Example Flow @@ -148,12 +154,12 @@ When a track is added, metadata is fetched asynchronously without blocking the r ### Provider Pipeline -| Provider | Data Retrieved | -|----------|---------------| -| **Discogs** | `artwork_url`, `release_year`, `discogs_url`, `bio`, `wikipedia_url` | -| **Spotify** | `spotify_url` | -| **Apple Music** | `apple_music_url` | -| **Search URLs** | `youtube_music_url`, `bandcamp_url`, `soundcloud_url` | +| Provider | Data Retrieved | +| --------------- | -------------------------------------------------------------------- | +| **Discogs** | `artwork_url`, `release_year`, `discogs_url`, `bio`, `wikipedia_url` | +| **Spotify** | `spotify_url` | +| **Apple Music** | `apple_music_url` | +| **Search URLs** | `youtube_music_url`, `bandcamp_url`, `soundcloud_url` | --- @@ -165,43 +171,43 @@ The database migration adds two new tables for metadata storage: ### Migration History -| Migration | Purpose | -|-----------|---------| -| `0021_user-table-migration.sql` | DJ refactor (already applied) | -| `0022_library_cross_reference.sql` | Artist/library crossreference tables | -| `0023_metadata_tables.sql` | **NEW** - album_metadata + artist_metadata | +| Migration | Purpose | +| ---------------------------------- | ------------------------------------------ | +| `0021_user-table-migration.sql` | DJ refactor (already applied) | +| `0022_library_cross_reference.sql` | Artist/library crossreference tables | +| `0023_metadata_tables.sql` | **NEW** - album_metadata + artist_metadata | ### New Tables #### `wxyc_schema.album_metadata` -| Column | Type | Purpose | -|--------|------|---------| -| `id` | serial | Primary key | -| `album_id` | integer (FK, unique) | Link to library for known albums | -| `cache_key` | varchar (unique) | Key for non-library entries | -| `artwork_url` | varchar | Album cover image URL | -| `spotify_url` | varchar | Spotify album link | -| `apple_music_url` | varchar | Apple Music album link | -| `discogs_url` | varchar | Discogs release link | -| `youtube_music_url` | varchar | YouTube Music search URL | -| `bandcamp_url` | varchar | Bandcamp search URL | -| `soundcloud_url` | varchar | SoundCloud search URL | -| `release_year` | smallint | Album release year | -| `is_rotation` | boolean | Whether album is in rotation | -| `last_accessed` | timestamp | For tracking usage | +| Column | Type | Purpose | +| ------------------- | -------------------- | -------------------------------- | +| `id` | serial | Primary key | +| `album_id` | integer (FK, unique) | Link to library for known albums | +| `cache_key` | varchar (unique) | Key for non-library entries | +| `artwork_url` | varchar | Album cover image URL | +| `spotify_url` | varchar | Spotify album link | +| `apple_music_url` | varchar | Apple Music album link | +| `discogs_url` | varchar | Discogs release link | +| `youtube_music_url` | varchar | YouTube Music search URL | +| `bandcamp_url` | varchar | Bandcamp search URL | +| `soundcloud_url` | varchar | SoundCloud search URL | +| `release_year` | smallint | Album release year | +| `is_rotation` | boolean | Whether album is in rotation | +| `last_accessed` | timestamp | For tracking usage | #### `wxyc_schema.artist_metadata` -| Column | Type | Purpose | -|--------|------|---------| -| `id` | serial | Primary key | -| `artist_id` | integer (FK, unique) | Link to artists for known artists | -| `cache_key` | varchar (unique) | Key for non-library artists | -| `discogs_artist_id` | integer | Discogs artist ID | -| `bio` | text | Artist biography | -| `wikipedia_url` | varchar | Wikipedia article link | -| `last_accessed` | timestamp | For tracking usage | +| Column | Type | Purpose | +| ------------------- | -------------------- | --------------------------------- | +| `id` | serial | Primary key | +| `artist_id` | integer (FK, unique) | Link to artists for known artists | +| `cache_key` | varchar (unique) | Key for non-library artists | +| `discogs_artist_id` | integer | Discogs artist ID | +| `bio` | text | Artist biography | +| `wikipedia_url` | varchar | Wikipedia article link | +| `last_accessed` | timestamp | For tracking usage | --- @@ -214,6 +220,7 @@ This diagram shows the complete request/response flow between all systems: ### Scenarios #### 1. Add Track + - Client POSTs new track - Backend inserts to DB - Fire-and-forget metadata fetch starts @@ -222,6 +229,7 @@ This diagram shows the complete request/response flow between all systems: - Metadata saved to DB for future requests #### 2. Get Entries + - Client GETs flowsheet entries - Query database with LEFT JOINs to include metadata - Return entries with metadata @@ -232,12 +240,12 @@ This diagram shows the complete request/response flow between all systems: ### Changes Made -| Area | Change | -|------|--------| -| **Migration** | Created 0023_metadata_tables.sql | -| **Flowsheet Service** | Added LEFT JOINs, last-modified tracking | -| **Flowsheet Controller** | Fire-and-forget metadata fetch on new entries | -| **Database** | New `album_metadata` and `artist_metadata` tables | +| Area | Change | +| ------------------------ | ------------------------------------------------- | +| **Migration** | Created 0023_metadata_tables.sql | +| **Flowsheet Service** | Added LEFT JOINs, last-modified tracking | +| **Flowsheet Controller** | Fire-and-forget metadata fetch on new entries | +| **Database** | New `album_metadata` and `artist_metadata` tables | ### Data Flow Summary @@ -249,19 +257,19 @@ This diagram shows the complete request/response flow between all systems: ### Core Implementation -| File | Change | -|------|--------| -| `apps/backend/services/flowsheet.service.ts` | Added LEFT JOINs, last-modified tracking | -| `apps/backend/controllers/flowsheet.controller.ts` | Fire-and-forget metadata fetch | -| `apps/backend/services/metadata/*` | Metadata service implementation | +| File | Change | +| -------------------------------------------------- | ---------------------------------------- | +| `apps/backend/services/flowsheet.service.ts` | Added LEFT JOINs, last-modified tracking | +| `apps/backend/controllers/flowsheet.controller.ts` | Fire-and-forget metadata fetch | +| `apps/backend/services/metadata/*` | Metadata service implementation | ### Database -| File | Change | -|------|--------| -| `shared/database/src/schema.ts` | Added `album_metadata` and `artist_metadata` tables | -| `shared/database/src/migrations/0023_metadata_tables.sql` | Migration for metadata tables | -| `shared/database/src/migrations/meta/_journal.json` | Added 0023 entry | +| File | Change | +| --------------------------------------------------------- | --------------------------------------------------- | +| `shared/database/src/schema.ts` | Added `album_metadata` and `artist_metadata` tables | +| `shared/database/src/migrations/0023_metadata_tables.sql` | Migration for metadata tables | +| `shared/database/src/migrations/meta/_journal.json` | Added 0023 entry | --- diff --git a/jest.unit.config.ts b/jest.unit.config.ts index acea718..405242e 100644 --- a/jest.unit.config.ts +++ b/jest.unit.config.ts @@ -6,13 +6,14 @@ const config: Config = { testMatch: ['/tests/unit/**/*.test.ts'], setupFilesAfterEnv: ['/tests/setup/unit.setup.ts'], transform: { - '^.+\\.tsx?$': ['ts-jest', { - tsconfig: '/tests/tsconfig.json', - }], + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tests/tsconfig.json', + }, + ], }, - transformIgnorePatterns: [ - 'node_modules/(?!(jose|drizzle-orm)/)', - ], + transformIgnorePatterns: ['node_modules/(?!(jose|drizzle-orm)/)'], moduleNameMapper: { // Mock workspace database package '^@wxyc/database$': '/tests/mocks/database.mock.ts', @@ -21,11 +22,7 @@ const config: Config = { // Remove .js extensions from relative imports (ESM compatibility) '^(\\.{1,2}/.*)\\.(js)$': '$1', }, - collectCoverageFrom: [ - 'apps/backend/**/*.ts', - '!**/*.d.ts', - '!**/dist/**', - ], + collectCoverageFrom: ['apps/backend/**/*.ts', '!**/*.d.ts', '!**/dist/**'], clearMocks: true, }; diff --git a/shared/authentication/src/auth.client.ts b/shared/authentication/src/auth.client.ts index 03ae64b..dca3bec 100644 --- a/shared/authentication/src/auth.client.ts +++ b/shared/authentication/src/auth.client.ts @@ -11,11 +11,5 @@ export const authClient = createAuthClient({ // Base URL for the auth service baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:8082/auth', - plugins: [ - adminClient(), - usernameClient(), - anonymousClient(), - jwtClient(), - organizationClient(), - ], + plugins: [adminClient(), usernameClient(), anonymousClient(), jwtClient(), organizationClient()], }); diff --git a/shared/authentication/src/auth.definition.ts b/shared/authentication/src/auth.definition.ts index 1be3ed7..fd3aa78 100644 --- a/shared/authentication/src/auth.definition.ts +++ b/shared/authentication/src/auth.definition.ts @@ -1,25 +1,8 @@ -import { - account, - db, - invitation, - jwks, - member, - organization, - session, - user, - verification, -} from '@wxyc/database'; +import { account, db, invitation, jwks, member, organization, session, user, verification } from '@wxyc/database'; import { betterAuth, type Auth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { createAuthMiddleware } from 'better-auth/api'; -import { - admin, - anonymous, - bearer, - jwt, - organization as organizationPlugin, - username, -} from 'better-auth/plugins'; +import { admin, anonymous, bearer, jwt, organization as organizationPlugin, username } from 'better-auth/plugins'; import { eq, sql } from 'drizzle-orm'; import { WXYCRoles } from './auth.roles'; import { sendResetPasswordEmail, sendVerificationEmailMessage } from './email'; @@ -60,11 +43,7 @@ export const auth: Auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:8082/auth', // Trusted origins for CORS - trustedOrigins: ( - process.env.BETTER_AUTH_TRUSTED_ORIGINS || - process.env.FRONTEND_SOURCE || - 'http://localhost:3000' - ) + trustedOrigins: (process.env.BETTER_AUTH_TRUSTED_ORIGINS || process.env.FRONTEND_SOURCE || 'http://localhost:3000') .split(',') .map((origin) => origin.trim()) .filter(Boolean), @@ -108,9 +87,9 @@ export const auth: Auth = betterAuth({ // Subdomain-friendly cookie setting (recommended over cross-site cookies) advanced: { defaultCookieAttributes: { - sameSite: "none", - secure: true - } + sameSite: 'none', + secure: true, + }, }, plugins: [ @@ -154,17 +133,11 @@ export const auth: Auth = betterAuth({ // Role information is included via custom JWT definePayload function above organizationHooks: { // Sync global user.role when members are added to default organization - afterAddMember: async ({ - member, - user: userData, - organization: orgData, - }) => { + afterAddMember: async ({ member, user: userData, organization: orgData }) => { try { const defaultOrgSlug = process.env.DEFAULT_ORG_SLUG; if (!defaultOrgSlug) { - console.warn( - 'DEFAULT_ORG_SLUG is not set, skipping admin role sync' - ); + console.warn('DEFAULT_ORG_SLUG is not set, skipping admin role sync'); return; } @@ -178,10 +151,7 @@ export const auth: Auth = betterAuth({ if (adminRoles.includes(member.role)) { // Update user.role to "admin" for Better Auth Admin plugin const userId = userData.id; - await db - .update(user) - .set({ role: 'admin' }) - .where(eq(user.id, userId)); + await db.update(user).set({ role: 'admin' }).where(eq(user.id, userId)); console.log( `Granted admin role to user ${userId} (${userData.email}) with ${member.role} role in default organization` ); @@ -192,18 +162,11 @@ export const auth: Auth = betterAuth({ }, // Sync global user.role when member roles are updated - afterUpdateMemberRole: async ({ - member, - previousRole, - user: userData, - organization: orgData, - }) => { + afterUpdateMemberRole: async ({ member, previousRole, user: userData, organization: orgData }) => { try { const defaultOrgSlug = process.env.DEFAULT_ORG_SLUG; if (!defaultOrgSlug) { - console.warn( - 'DEFAULT_ORG_SLUG is not set, skipping admin role sync' - ); + console.warn('DEFAULT_ORG_SLUG is not set, skipping admin role sync'); return; } @@ -219,42 +182,26 @@ export const auth: Auth = betterAuth({ const userId = userData.id; if (shouldHaveAdmin && !previouslyHadAdmin) { // Promoted to admin role - grant admin - await db - .update(user) - .set({ role: 'admin' }) - .where(eq(user.id, userId)); - console.log( - `Granted admin role to user ${userId} (${userData.email}) after promotion to ${member.role}` - ); + await db.update(user).set({ role: 'admin' }).where(eq(user.id, userId)); + console.log(`Granted admin role to user ${userId} (${userData.email}) after promotion to ${member.role}`); } else if (!shouldHaveAdmin && previouslyHadAdmin) { // Demoted from admin role - remove admin - await db - .update(user) - .set({ role: null }) - .where(eq(user.id, userId)); + await db.update(user).set({ role: null }).where(eq(user.id, userId)); console.log( `Removed admin role from user ${userId} (${userData.email}) after demotion from ${previousRole} to ${member.role}` ); } } catch (error) { - console.error( - 'Error syncing admin role in afterUpdateMemberRole:', - error - ); + console.error('Error syncing admin role in afterUpdateMemberRole:', error); } }, // Sync global user.role when members are removed from default organization - afterRemoveMember: async ({ - user: userData, - organization: orgData, - }) => { + afterRemoveMember: async ({ user: userData, organization: orgData }) => { try { const defaultOrgSlug = process.env.DEFAULT_ORG_SLUG; if (!defaultOrgSlug) { - console.warn( - 'DEFAULT_ORG_SLUG is not set, skipping admin role sync' - ); + console.warn('DEFAULT_ORG_SLUG is not set, skipping admin role sync'); return; } @@ -267,10 +214,7 @@ export const auth: Auth = betterAuth({ const otherAdminMemberships = await db .select({ role: member.role }) .from(member) - .innerJoin( - organization, - sql`${member.organizationId} = ${organization.id}` as any - ) + .innerJoin(organization, sql`${member.organizationId} = ${organization.id}` as any) .where( sql`${member.userId} = ${userData.id} AND ${organization.slug} = ${defaultOrgSlug} @@ -281,19 +225,13 @@ export const auth: Auth = betterAuth({ // If no other admin memberships exist, remove admin role if (otherAdminMemberships.length === 0) { const userId = userData.id; - await db - .update(user) - .set({ role: null }) - .where(eq(user.id, userId)); + await db.update(user).set({ role: null }).where(eq(user.id, userId)); console.log( `Removed admin role from user ${userId} (${userData.email}) after removal from default organization` ); } } catch (error) { - console.error( - 'Error syncing admin role in afterRemoveMember:', - error - ); + console.error('Error syncing admin role in afterRemoveMember:', error); } }, }, @@ -312,9 +250,7 @@ export const auth: Auth = betterAuth({ } // Send verification email for admin-created users - const callbackURL = - process.env.EMAIL_VERIFICATION_REDIRECT_URL?.trim() || - process.env.FRONTEND_SOURCE?.trim(); + const callbackURL = process.env.EMAIL_VERIFICATION_REDIRECT_URL?.trim() || process.env.FRONTEND_SOURCE?.trim(); void auth.api .sendVerificationEmail({ diff --git a/shared/authentication/src/auth.middleware.ts b/shared/authentication/src/auth.middleware.ts index 89e155f..97e9697 100644 --- a/shared/authentication/src/auth.middleware.ts +++ b/shared/authentication/src/auth.middleware.ts @@ -1,16 +1,16 @@ -import { NextFunction, Request, Response } from "express"; -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; -import { AccessControlStatement, WXYCRole, WXYCRoles } from "./auth.roles"; +import { NextFunction, Request, Response } from 'express'; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; +import { AccessControlStatement, WXYCRole, WXYCRoles } from './auth.roles'; // JWT payload structure expected from better-auth JWT plugin // When used with organization plugin, tokens include user info and organization role // Standard JWT fields: 'sub' (user ID), 'email' // Organization plugin adds: organization context with member role -export type WXYCAuthJwtPayload = JWTPayload & { - id?: string; // User ID (may be in 'sub' field, better-auth may map it) - sub?: string; // Standard JWT subject (user ID) - email: string; - role: WXYCRole; // Organization member role from better-auth organization plugin +export type WXYCAuthJwtPayload = JWTPayload & { + id?: string; // User ID (may be in 'sub' field, better-auth may map it) + sub?: string; // Standard JWT subject (user ID) + email: string; + role: WXYCRole; // Organization member role from better-auth organization plugin }; const issuer = process.env.BETTER_AUTH_ISSUER; @@ -18,16 +18,14 @@ const audience = process.env.BETTER_AUTH_AUDIENCE; const jwksUrl = process.env.BETTER_AUTH_JWKS_URL; if (!jwksUrl) { - throw new Error("BETTER_AUTH_JWKS_URL environment variable is not set."); + throw new Error('BETTER_AUTH_JWKS_URL environment variable is not set.'); } const JWKS = createRemoteJWKSet(new URL(jwksUrl)); async function verify(token: string) { if (!issuer || !audience) { - throw new Error( - "JWT verification environment variables are not properly set." - ); + throw new Error('JWT verification environment variables are not properly set.'); } try { @@ -42,7 +40,7 @@ async function verify(token: string) { } } -declare module "express-serve-static-core" { +declare module 'express-serve-static-core' { interface Request { auth?: Awaited>; } @@ -54,26 +52,24 @@ export type RequiredPermissions = { export function requirePermissions(required: RequiredPermissions) { return async (req: Request, res: Response, next: NextFunction) => { - if (process.env.AUTH_BYPASS === "true") { + if (process.env.AUTH_BYPASS === 'true') { return next(); } // Extract Bearer token from Authorization header const authHeader = req.headers.authorization; - + if (!authHeader) { - return res.status(401).json({ error: "Unauthorized: Missing Authorization header." }); + return res.status(401).json({ error: 'Unauthorized: Missing Authorization header.' }); } // Extract token from Authorization header // Support both "Bearer " and plain "" formats // Tests may use plain format, but Bearer format is standard - const token = authHeader.startsWith("Bearer ") - ? authHeader.slice(7).trim() - : authHeader.trim(); + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : authHeader.trim(); if (!token) { - return res.status(401).json({ error: "Unauthorized: Missing token in Authorization header." }); + return res.status(401).json({ error: 'Unauthorized: Missing token in Authorization header.' }); } // Verify JWT using JWKS from better-auth service @@ -81,14 +77,14 @@ export function requirePermissions(required: RequiredPermissions) { try { payload = await verify(token); } catch (error) { - console.error("JWT verification failed:", error); - return res.status(401).json({ error: "Unauthorized: Invalid or expired token." }); + console.error('JWT verification failed:', error); + return res.status(401).json({ error: 'Unauthorized: Invalid or expired token.' }); } // Normalize user ID - JWT standard uses 'sub', but better-auth may also include 'id' const userId = payload.id || payload.sub; if (!userId) { - return res.status(403).json({ error: "Forbidden: Missing user ID in token." }); + return res.status(403).json({ error: 'Forbidden: Missing user ID in token.' }); } // Attach authenticated payload to request (ensure id field is set) @@ -99,31 +95,29 @@ export function requirePermissions(required: RequiredPermissions) { // Validate role exists if (!payload.role) { - return res.status(403).json({ error: "Forbidden: Missing role in token." }); + return res.status(403).json({ error: 'Forbidden: Missing role in token.' }); } const roleImpl = WXYCRoles[payload.role]; if (!roleImpl) { - return res.status(403).json({ error: "Forbidden: Invalid role." }); + return res.status(403).json({ error: 'Forbidden: Invalid role.' }); } // Check permissions const ok = Object.entries(required).every(([resource, actions]) => { if (!actions || actions.length === 0) return true; - const authorize = roleImpl.authorize as ( - request: RequiredPermissions - ) => { success: boolean }; - + const authorize = roleImpl.authorize as (request: RequiredPermissions) => { success: boolean }; + const result = authorize({ [resource]: actions, } as RequiredPermissions); - + return result.success; }); if (!ok) { - return res.status(403).json({ error: "Forbidden: insufficient permissions" }); + return res.status(403).json({ error: 'Forbidden: insufficient permissions' }); } return next(); diff --git a/shared/authentication/src/auth.roles.ts b/shared/authentication/src/auth.roles.ts index 386f3e1..fbfea1d 100644 --- a/shared/authentication/src/auth.roles.ts +++ b/shared/authentication/src/auth.roles.ts @@ -1,14 +1,11 @@ -import { createAccessControl } from "better-auth/plugins/access"; -import { - adminAc, - defaultStatements, -} from "better-auth/plugins/organization/access"; +import { createAccessControl } from 'better-auth/plugins/access'; +import { adminAc, defaultStatements } from 'better-auth/plugins/organization/access'; const statement = { ...defaultStatements, - catalog: ["read", "write"], - bin: ["read", "write"], - flowsheet: ["read", "write"], + catalog: ['read', 'write'], + bin: ['read', 'write'], + flowsheet: ['read', 'write'], } as const; export type AccessControlStatement = typeof statement; @@ -16,28 +13,28 @@ export type AccessControlStatement = typeof statement; const accessControl = createAccessControl(statement); export const member = accessControl.newRole({ - bin: ["read", "write"], - catalog: ["read"], - flowsheet: ["read"], + bin: ['read', 'write'], + catalog: ['read'], + flowsheet: ['read'], }); export const dj = accessControl.newRole({ - bin: ["read", "write"], - catalog: ["read"], - flowsheet: ["read", "write"], + bin: ['read', 'write'], + catalog: ['read'], + flowsheet: ['read', 'write'], }); export const musicDirector = accessControl.newRole({ - bin: ["read", "write"], - catalog: ["read", "write"], - flowsheet: ["read", "write"], + bin: ['read', 'write'], + catalog: ['read', 'write'], + flowsheet: ['read', 'write'], }); export const stationManager = accessControl.newRole({ ...adminAc.statements, - bin: ["read", "write"], - catalog: ["read", "write"], - flowsheet: ["read", "write"], + bin: ['read', 'write'], + catalog: ['read', 'write'], + flowsheet: ['read', 'write'], }); export const WXYCRoles = { diff --git a/shared/authentication/src/email.ts b/shared/authentication/src/email.ts index 2f54e28..783216c 100644 --- a/shared/authentication/src/email.ts +++ b/shared/authentication/src/email.ts @@ -12,9 +12,7 @@ const getSesClient = () => { const region = process.env.AWS_REGION; if (!accessKeyId || !secretAccessKey || !region) { - throw new Error( - 'Missing AWS SES configuration: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION' - ); + throw new Error('Missing AWS SES configuration: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION'); } sesClient = new SESClient({ @@ -43,13 +41,8 @@ type EmailTemplateInput = { footer?: string; }; -const buildEmailHtml = ({ - title, - intro, - actionText, - actionUrl, - footer, -}: EmailTemplateInput) => ` +const buildEmailHtml = ({ title, intro, actionText, actionUrl, footer }: EmailTemplateInput) => + `
@@ -87,10 +80,7 @@ const buildEmailHtml = ({ `.trim(); -export const sendResetPasswordEmail = async ({ - to, - resetUrl, -}: ResetEmailInput) => { +export const sendResetPasswordEmail = async ({ to, resetUrl }: ResetEmailInput) => { const from = process.env.SES_FROM_EMAIL; if (!from) { throw new Error('Missing AWS SES configuration: SES_FROM_EMAIL'); @@ -121,10 +111,7 @@ export const sendResetPasswordEmail = async ({ await client.send(command); }; -export const sendVerificationEmailMessage = async ({ - to, - verificationUrl, -}: VerificationEmailInput) => { +export const sendVerificationEmailMessage = async ({ to, verificationUrl }: VerificationEmailInput) => { const from = process.env.SES_FROM_EMAIL; if (!from) { throw new Error('Missing AWS SES configuration: SES_FROM_EMAIL'); diff --git a/shared/authentication/src/index.ts b/shared/authentication/src/index.ts index d2c0053..1696494 100644 --- a/shared/authentication/src/index.ts +++ b/shared/authentication/src/index.ts @@ -1,3 +1,3 @@ -export * from "./auth.definition"; -export * from "./auth.roles"; -export * from "./auth.middleware"; \ No newline at end of file +export * from './auth.definition'; +export * from './auth.roles'; +export * from './auth.middleware'; diff --git a/shared/authentication/tsconfig.build.json b/shared/authentication/tsconfig.build.json index 97680ca..9c02639 100644 --- a/shared/authentication/tsconfig.build.json +++ b/shared/authentication/tsconfig.build.json @@ -1,7 +1,6 @@ { - "extends": ["./tsconfig.json"], - "compilerOptions": { - "composite": false - } + "extends": ["./tsconfig.json"], + "compilerOptions": { + "composite": false + } } - diff --git a/shared/authentication/tsconfig.json b/shared/authentication/tsconfig.json index c1048d7..972d58c 100644 --- a/shared/authentication/tsconfig.json +++ b/shared/authentication/tsconfig.json @@ -1,15 +1,15 @@ { - "extends": ["../../tsconfig.base.json"], - "references": [{ "path": "../database" }], - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "composite": true, - "baseUrl": ".", - "rootDir": ".", - "outDir": "./dist", - "module": "esnext", - "moduleResolution": "bundler", - "strict": true - } -} \ No newline at end of file + "extends": ["../../tsconfig.base.json"], + "references": [{ "path": "../database" }], + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "rootDir": ".", + "outDir": "./dist", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true + } +} diff --git a/shared/authentication/tsup.config.ts b/shared/authentication/tsup.config.ts index 3d15b73..d7f55f3 100644 --- a/shared/authentication/tsup.config.ts +++ b/shared/authentication/tsup.config.ts @@ -2,11 +2,11 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], - format: ["esm", "cjs"], + format: ['esm', 'cjs'], dts: true, tsconfig: './tsconfig.build.json', outDir: 'dist', clean: true, sourcemap: true, - external: ["drizzle-orm", "postgres", "better-auth" ] + external: ['drizzle-orm', 'postgres', 'better-auth'], }); diff --git a/shared/database/package.json b/shared/database/package.json index 35c48b8..b5b2da7 100644 --- a/shared/database/package.json +++ b/shared/database/package.json @@ -26,5 +26,5 @@ "tsup": "^8.5.0", "typescript": "^5.6.2" }, - "types" : "dist/index.d.ts" + "types": "dist/index.d.ts" } diff --git a/shared/database/src/client.ts b/shared/database/src/client.ts index b783133..4ff4b94 100644 --- a/shared/database/src/client.ts +++ b/shared/database/src/client.ts @@ -1,10 +1,10 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import * as schema from "./schema"; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; // Validate required environment variables const requiredEnvVars = ['DB_HOST', 'DB_NAME', 'DB_USERNAME', 'DB_PASSWORD']; -const missingVars = requiredEnvVars.filter(v => !process.env[v]); +const missingVars = requiredEnvVars.filter((v) => !process.env[v]); if (missingVars.length > 0) { console.error('[ERROR] Missing required database environment variables:', missingVars.join(', ')); throw new Error(`Missing required database environment variables: ${missingVars.join(', ')}`); diff --git a/shared/database/src/index.ts b/shared/database/src/index.ts index a6127d0..ecc55c8 100644 --- a/shared/database/src/index.ts +++ b/shared/database/src/index.ts @@ -1,3 +1,3 @@ -export * from "./client.js"; -export * from "./schema.js"; -export * from "./types/index.js"; \ No newline at end of file +export * from './client.js'; +export * from './schema.js'; +export * from './types/index.js'; diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 1962f6f..f17f2b3 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -265,7 +265,14 @@ 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' + 'track', + 'show_start', + 'show_end', + 'dj_join', + 'dj_leave', + 'talkset', + 'breakpoint', + 'message', ]); export const rotation = wxyc_schema.table( 'rotation', @@ -464,7 +471,9 @@ export const album_metadata = wxyc_schema.table( 'album_metadata', { id: serial('id').primaryKey(), - album_id: integer('album_id').references(() => library.id).unique(), // FK to library - for known albums + album_id: integer('album_id') + .references(() => library.id) + .unique(), // FK to library - for known albums cache_key: varchar('cache_key', { length: 512 }).unique(), // For unknown albums (no album_id) // Discogs metadata @@ -500,7 +509,9 @@ export const artist_metadata = wxyc_schema.table( 'artist_metadata', { id: serial('id').primaryKey(), - artist_id: integer('artist_id').references(() => artists.id).unique(), // FK to artists - for known artists + artist_id: integer('artist_id') + .references(() => artists.id) + .unique(), // FK to artists - for known artists cache_key: varchar('cache_key', { length: 256 }).unique(), // For unknown artists (no artist_id) // Discogs artist data diff --git a/shared/database/src/types/flowsheet.types.ts b/shared/database/src/types/flowsheet.types.ts index 9640ee4..7a8cf26 100644 --- a/shared/database/src/types/flowsheet.types.ts +++ b/shared/database/src/types/flowsheet.types.ts @@ -109,4 +109,3 @@ export type FlowsheetEntryV2 = | TalksetEntryV2 | BreakpointEntryV2 | MessageEntryV2; - diff --git a/shared/database/tsconfig.build.json b/shared/database/tsconfig.build.json index 97680ca..9c02639 100644 --- a/shared/database/tsconfig.build.json +++ b/shared/database/tsconfig.build.json @@ -1,7 +1,6 @@ { - "extends": ["./tsconfig.json"], - "compilerOptions": { - "composite": false - } + "extends": ["./tsconfig.json"], + "compilerOptions": { + "composite": false + } } - diff --git a/shared/database/tsconfig.json b/shared/database/tsconfig.json index 96e9b81..30a6fde 100644 --- a/shared/database/tsconfig.json +++ b/shared/database/tsconfig.json @@ -1,14 +1,14 @@ { - "extends": ["../../tsconfig.base.json"], - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "composite": true, - "baseUrl": ".", - "rootDir": ".", - "outDir": "./dist", - "module": "esnext", - "moduleResolution": "bundler", - "strict": true - } -} \ No newline at end of file + "extends": ["../../tsconfig.base.json"], + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "rootDir": ".", + "outDir": "./dist", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true + } +} diff --git a/shared/database/tsup.config.ts b/shared/database/tsup.config.ts index f0bfb65..ff430f6 100644 --- a/shared/database/tsup.config.ts +++ b/shared/database/tsup.config.ts @@ -2,11 +2,11 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], - format: ["esm", "cjs"], + format: ['esm', 'cjs'], dts: true, tsconfig: './tsconfig.build.json', outDir: 'dist', clean: true, sourcemap: true, - external: ["drizzle-orm", "postgres" ] + external: ['drizzle-orm', 'postgres'], }); diff --git a/tests/integration/discogs.spec.js b/tests/integration/discogs.spec.js index cf19c08..d5d6c9b 100644 --- a/tests/integration/discogs.spec.js +++ b/tests/integration/discogs.spec.js @@ -134,19 +134,14 @@ describe('Discogs Service', () => { it('should handle malformed requests gracefully', async () => { const { token } = await getTestToken(); - const response = await request - .post('/request') - .set('Authorization', `Bearer ${token}`) - .send({ message: '' }); + const response = await request.post('/request').set('Authorization', `Bearer ${token}`).send({ message: '' }); // Should handle empty messages expect(response.status).toBe(400); }); it('should handle requests without authentication', async () => { - const response = await request - .post('/request') - .send({ message: 'Play something' }); + const response = await request.post('/request').send({ message: 'Play something' }); expect(response.status).toBe(401); }); diff --git a/tests/integration/djs.spec.js b/tests/integration/djs.spec.js index 81dcb01..46e0cc3 100644 --- a/tests/integration/djs.spec.js +++ b/tests/integration/djs.spec.js @@ -107,10 +107,7 @@ describe('DJ Bin', () => { }); test('removes entry from bin successfully', async () => { - const res = await auth - .delete('/djs/bin') - .query({ dj_id: global.primary_dj_id, album_id: 2 }) - .expect(200); + const res = await auth.delete('/djs/bin').query({ dj_id: global.primary_dj_id, album_id: 2 }).expect(200); expect(res.body).toBeDefined(); diff --git a/tests/integration/events.spec.js b/tests/integration/events.spec.js index e3bb959..0d0ba3c 100644 --- a/tests/integration/events.spec.js +++ b/tests/integration/events.spec.js @@ -135,7 +135,10 @@ describe('Server-Sent Events', () => { describe('PUT /events/subscribe', () => { test('returns 400 when client_id is missing', async () => { - const res = await auth.put('/events/subscribe').send({ topics: ['test'] }).expect(400); + const res = await auth + .put('/events/subscribe') + .send({ topics: ['test'] }) + .expect(400); expectErrorContains(res, 'client_id'); }); diff --git a/tests/integration/flowsheet.spec.js b/tests/integration/flowsheet.spec.js index ab916e4..28196b4 100644 --- a/tests/integration/flowsheet.spec.js +++ b/tests/integration/flowsheet.spec.js @@ -626,10 +626,7 @@ describe('On Air Status', () => { // Ensure no active show await fls_util.leave_show(global.primary_dj_id, global.access_token); - const res = await request - .get('/flowsheet/djs-on-air') - .set('Authorization', global.access_token) - .expect(200); + const res = await request.get('/flowsheet/djs-on-air').set('Authorization', global.access_token).expect(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body.length).toBe(0); @@ -639,10 +636,7 @@ describe('On Air Status', () => { // Start a show await fls_util.join_show(global.primary_dj_id, global.access_token); - const res = await request - .get('/flowsheet/djs-on-air') - .set('Authorization', global.access_token) - .expect(200); + const res = await request.get('/flowsheet/djs-on-air').set('Authorization', global.access_token).expect(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body.length).toBeGreaterThan(0); @@ -659,10 +653,7 @@ describe('On Air Status', () => { // Secondary DJ joins await fls_util.join_show(global.secondary_dj_id, global.access_token); - const res = await request - .get('/flowsheet/djs-on-air') - .set('Authorization', global.access_token) - .expect(200); + const res = await request.get('/flowsheet/djs-on-air').set('Authorization', global.access_token).expect(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body.length).toBe(2); @@ -764,16 +755,9 @@ describe('V2 Playlist - Discriminated Union Format', () => { // 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); + expect(['track', 'show_start', 'show_end', 'dj_join', 'dj_leave', 'talkset', 'breakpoint', 'message']).toContain( + entry.entry_type + ); }); // Track entries should not have message field diff --git a/tests/integration/library.spec.js b/tests/integration/library.spec.js index a542e27..d8eb002 100644 --- a/tests/integration/library.spec.js +++ b/tests/integration/library.spec.js @@ -41,7 +41,10 @@ describe('Library Catalog', () => { }); test('searches by both artist and album', async () => { - const res = await auth.get('/library').query({ artist_name: 'Built to Spill', album_title: 'Keep it' }).expect(200); + const res = await auth + .get('/library') + .query({ artist_name: 'Built to Spill', album_title: 'Keep it' }) + .expect(200); expectArray(res); }); diff --git a/tests/integration/metadata.spec.js b/tests/integration/metadata.spec.js index 30854d4..7a861df 100644 --- a/tests/integration/metadata.spec.js +++ b/tests/integration/metadata.spec.js @@ -490,9 +490,7 @@ describe('Conditional GET (304 Not Modified)', () => { expect(updatedRes.body.length).toBeGreaterThan(0); expect(updatedRes.headers['last-modified']).toBeDefined(); // The new Last-Modified should be different (later) than the old one - expect(new Date(updatedRes.headers['last-modified']).getTime()).toBeGreaterThan( - new Date(lastModified).getTime() - ); + expect(new Date(updatedRes.headers['last-modified']).getTime()).toBeGreaterThan(new Date(lastModified).getTime()); }); test('/flowsheet/latest returns 304 when not modified', async () => { @@ -533,11 +531,7 @@ describe('Conditional GET (304 Not Modified)', () => { const lastModified = initialRes.headers['last-modified']; // Second request with since query param - const cachedRes = await request - .get('/flowsheet') - .query({ limit: 10, since: lastModified }) - .send() - .expect(304); + const cachedRes = await request.get('/flowsheet').query({ limit: 10, since: lastModified }).send().expect(304); expect(cachedRes.body).toEqual({}); }); @@ -558,21 +552,13 @@ describe('Conditional GET (304 Not Modified)', () => { .expect(200); // Request with old since param should return 200 with new data - const updatedRes = await request - .get('/flowsheet') - .query({ limit: 10, since: lastModified }) - .send() - .expect(200); + const updatedRes = await request.get('/flowsheet').query({ limit: 10, since: lastModified }).send().expect(200); expect(updatedRes.body.length).toBeGreaterThan(0); }); test('Returns 200 when since param is invalid date', async () => { - const res = await request - .get('/flowsheet') - .query({ limit: 10, since: 'invalid-date' }) - .send() - .expect(200); + const res = await request.get('/flowsheet').query({ limit: 10, since: 'invalid-date' }).send().expect(200); expect(res.headers['last-modified']).toBeDefined(); }); diff --git a/tests/integration/requestLine.spec.js b/tests/integration/requestLine.spec.js index 1882f0f..7772e0c 100644 --- a/tests/integration/requestLine.spec.js +++ b/tests/integration/requestLine.spec.js @@ -74,30 +74,21 @@ describe('Request Line Endpoint', () => { }); it('should return 400 when message field is missing', async () => { - const response = await request - .post('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({}); + const response = await request.post('/request').set('Authorization', `Bearer ${testToken}`).send({}); expect(response.status).toBe(400); expect(response.body.message).toMatch(/missing/i); }); it('should return 400 when request body is empty object', async () => { - const response = await request - .post('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({}); + const response = await request.post('/request').set('Authorization', `Bearer ${testToken}`).send({}); expect(response.status).toBe(400); expect(response.body.message).toMatch(/missing/i); }); it('should return 400 for empty string message', async () => { - const response = await request - .post('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({ message: '' }); + const response = await request.post('/request').set('Authorization', `Bearer ${testToken}`).send({ message: '' }); expect(response.status).toBe(400); expect(response.body.message).toMatch(/empty/i); @@ -265,14 +256,11 @@ describe('Request Line Endpoint', () => { }); it('should ignore extra fields in request body', async () => { - const response = await request - .post('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({ - message: 'Test message', - extraField: 'should be ignored', - anotherExtra: 123, - }); + const response = await request.post('/request').set('Authorization', `Bearer ${testToken}`).send({ + message: 'Test message', + extraField: 'should be ignored', + anotherExtra: 123, + }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); diff --git a/tests/mocks/database.mock.ts b/tests/mocks/database.mock.ts index 9119aee..64a7e21 100644 --- a/tests/mocks/database.mock.ts +++ b/tests/mocks/database.mock.ts @@ -21,11 +21,21 @@ export function createMockQueryChain(resolvedValue: unknown = []): MockQueryChai const chain: MockQueryChain = {} as MockQueryChain; const chainMethods = [ - 'select', 'from', 'where', 'innerJoin', 'leftJoin', - 'orderBy', 'limit', 'insert', 'values', 'update', 'set', 'delete' + 'select', + 'from', + 'where', + 'innerJoin', + 'leftJoin', + 'orderBy', + 'limit', + 'insert', + 'values', + 'update', + 'set', + 'delete', ]; - chainMethods.forEach(method => { + chainMethods.forEach((method) => { (chain as Record)[method] = jest.fn().mockReturnValue(chain); }); @@ -61,7 +71,21 @@ 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 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 = {}; diff --git a/tests/unit/middleware/legacy/flowsheet.mirror.test.ts b/tests/unit/middleware/legacy/flowsheet.mirror.test.ts index 453c569..d45d4f8 100644 --- a/tests/unit/middleware/legacy/flowsheet.mirror.test.ts +++ b/tests/unit/middleware/legacy/flowsheet.mirror.test.ts @@ -118,13 +118,24 @@ describe('flowsheet.mirror SQL generation', () => { it('generates INSERT statement with all required columns', () => { const requiredColumns = [ - 'ID', 'STARTING_RADIO_HOUR', 'DJ_NAME', 'DJ_ID', 'DJ_HANDLE', - 'SHOW_NAME', 'SPECIALTY_SHOW_ID', 'WORKING_HOUR', 'SIGNON_TIME', - 'SIGNOFF_TIME', 'TIME_LAST_MODIFIED', 'TIME_CREATED', 'MODLOCK', 'SHOW_ID' + 'ID', + 'STARTING_RADIO_HOUR', + 'DJ_NAME', + 'DJ_ID', + 'DJ_HANDLE', + 'SHOW_NAME', + 'SPECIALTY_SHOW_ID', + 'WORKING_HOUR', + 'SIGNON_TIME', + 'SIGNOFF_TIME', + 'TIME_LAST_MODIFIED', + 'TIME_CREATED', + 'MODLOCK', + 'SHOW_ID', ]; const insertTemplate = `INSERT INTO ${RADIO_SHOW_TABLE}`; expect(insertTemplate).toContain(RADIO_SHOW_TABLE); - requiredColumns.forEach(col => { + requiredColumns.forEach((col) => { expect(requiredColumns).toContain(col); }); }); @@ -179,14 +190,30 @@ describe('flowsheet.mirror SQL generation', () => { it('generates INSERT with all required columns for track entry', () => { const requiredColumns = [ - 'ID', 'ARTIST_NAME', 'ARTIST_ID', 'SONG_TITLE', 'RELEASE_TITLE', - 'RELEASE_FORMAT_ID', 'LIBRARY_RELEASE_ID', 'ROTATION_RELEASE_ID', - 'LABEL_NAME', 'RADIO_HOUR', 'START_TIME', 'STOP_TIME', 'RADIO_SHOW_ID', - 'SEQUENCE_WITHIN_SHOW', 'NOW_PLAYING_FLAG', 'FLOWSHEET_ENTRY_TYPE_CODE_ID', - 'TIME_LAST_MODIFIED', 'TIME_CREATED', 'REQUEST_FLAG', 'GLOBAL_ORDER_ID', 'BMI_COMPOSER' + 'ID', + 'ARTIST_NAME', + 'ARTIST_ID', + 'SONG_TITLE', + 'RELEASE_TITLE', + 'RELEASE_FORMAT_ID', + 'LIBRARY_RELEASE_ID', + 'ROTATION_RELEASE_ID', + 'LABEL_NAME', + 'RADIO_HOUR', + 'START_TIME', + 'STOP_TIME', + 'RADIO_SHOW_ID', + 'SEQUENCE_WITHIN_SHOW', + 'NOW_PLAYING_FLAG', + 'FLOWSHEET_ENTRY_TYPE_CODE_ID', + 'TIME_LAST_MODIFIED', + 'TIME_CREATED', + 'REQUEST_FLAG', + 'GLOBAL_ORDER_ID', + 'BMI_COMPOSER', ]; - requiredColumns.forEach(col => { + requiredColumns.forEach((col) => { expect(requiredColumns).toContain(col); }); }); @@ -314,7 +341,7 @@ describe('flowsheet.mirror SQL generation', () => { it('detects breakpoint from message pattern (legacy)', () => { const entry = createMockEntry({ entry_type: undefined, - message: 'BREAKPOINT - TOP OF HOUR' + message: 'BREAKPOINT - TOP OF HOUR', }); expect(entry.message?.toLowerCase()).toContain('breakpoint'); }); @@ -322,7 +349,7 @@ describe('flowsheet.mirror SQL generation', () => { it('detects show start from message pattern (legacy)', () => { const entry = createMockEntry({ entry_type: undefined, - message: 'DJ Name signed on at 10:00 AM' + message: 'DJ Name signed on at 10:00 AM', }); expect(entry.message?.toLowerCase()).toContain('signed on'); }); @@ -330,7 +357,7 @@ describe('flowsheet.mirror SQL generation', () => { it('detects show end from message pattern (legacy)', () => { const entry = createMockEntry({ entry_type: undefined, - message: 'DJ Name signed off at 12:00 PM' + message: 'DJ Name signed off at 12:00 PM', }); expect(entry.message?.toLowerCase()).toContain('signed off'); }); @@ -338,7 +365,7 @@ describe('flowsheet.mirror SQL generation', () => { it('defaults to talkset for unrecognized messages', () => { const entry = createMockEntry({ entry_type: 'message', - message: 'Random DJ comment' + message: 'Random DJ comment', }); // Should map to talkset (type 7) expect(entry.entry_type).toBe('message'); diff --git a/tests/unit/services/anonymousDevice.service.test.ts b/tests/unit/services/anonymousDevice.service.test.ts index b98f9e2..691e5be 100644 --- a/tests/unit/services/anonymousDevice.service.test.ts +++ b/tests/unit/services/anonymousDevice.service.test.ts @@ -29,11 +29,7 @@ jest.mock('drizzle-orm', () => ({ import { isValidDeviceId, tokenNeedsRefresh } from '../../../apps/backend/services/anonymousDevice.service'; import { daysFromNow } from '../../utils/time'; -import { - VALID_UUIDS, - INVALID_UUIDS, - TEST_UUID_UPPERCASE, -} from '../../utils/constants'; +import { VALID_UUIDS, INVALID_UUIDS, TEST_UUID_UPPERCASE } from '../../utils/constants'; describe('anonymousDevice.service', () => { describe('isValidDeviceId', () => { diff --git a/tests/unit/services/flowsheet.service.test.ts b/tests/unit/services/flowsheet.service.test.ts index c15e0de..10f3602 100644 --- a/tests/unit/services/flowsheet.service.test.ts +++ b/tests/unit/services/flowsheet.service.test.ts @@ -337,15 +337,16 @@ describe('flowsheet.service', () => { 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', + 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); diff --git a/tests/unit/services/metadata.cache.test.ts b/tests/unit/services/metadata.cache.test.ts index 4a1ab09..cc468ec 100644 --- a/tests/unit/services/metadata.cache.test.ts +++ b/tests/unit/services/metadata.cache.test.ts @@ -9,10 +9,7 @@ jest.mock('drizzle-orm', () => ({ eq: jest.fn(), })); -import { - generateAlbumCacheKey, - generateArtistCacheKey, -} from '../../../apps/backend/services/metadata/metadata.cache'; +import { generateAlbumCacheKey, generateArtistCacheKey } from '../../../apps/backend/services/metadata/metadata.cache'; describe('metadata.cache', () => { describe('generateAlbumCacheKey', () => { diff --git a/tests/utils/db.js b/tests/utils/db.js index 2f27c66..eae981f 100644 --- a/tests/utils/db.js +++ b/tests/utils/db.js @@ -63,12 +63,14 @@ async function closeTestDb() { */ async function withRollback(fn) { const sql = getTestDb(); - await sql.begin(async (tx) => { - await fn(tx); - throw new Error('ROLLBACK'); // Force rollback - }).catch((err) => { - if (err.message !== 'ROLLBACK') throw err; - }); + await sql + .begin(async (tx) => { + await fn(tx); + throw new Error('ROLLBACK'); // Force rollback + }) + .catch((err) => { + if (err.message !== 'ROLLBACK') throw err; + }); } module.exports = { diff --git a/tests/utils/library_util.js b/tests/utils/library_util.js index 4e9127e..123e2d6 100644 --- a/tests/utils/library_util.js +++ b/tests/utils/library_util.js @@ -184,4 +184,3 @@ exports.searchLibrary = async (searchParams, access_token) => { return res; }; - diff --git a/tests/utils/time.ts b/tests/utils/time.ts index ec59317..e4ef993 100644 --- a/tests/utils/time.ts +++ b/tests/utils/time.ts @@ -13,11 +13,9 @@ export const nowInSeconds = (): number => Math.floor(Date.now() / 1000); * Returns a Unix timestamp N days from now. * Positive values = future, negative values = past. */ -export const daysFromNow = (days: number): number => - nowInSeconds() + days * SECONDS_PER_DAY; +export const daysFromNow = (days: number): number => nowInSeconds() + days * SECONDS_PER_DAY; /** * Returns a Unix timestamp N hours from now. */ -export const hoursFromNow = (hours: number): number => - nowInSeconds() + hours * 60 * 60; +export const hoursFromNow = (hours: number): number => nowInSeconds() + hours * 60 * 60; From a8337aad885549256361fc0aace9e7bd1a207aed Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 14:27:22 -0800 Subject: [PATCH 5/8] ci: add typecheck, lint, and format steps to CI workflow Add three new steps before the build step in lint-and-typecheck job: tsc --noEmit for type checking, eslint for linting, and prettier --check for formatting verification. --- .github/workflows/test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c633fb..290359d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,7 +140,16 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' || steps.validate-cache.outputs.valid == 'false' run: npm ci - - name: Build (includes type checking) + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Build run: npm run build # Unit tests - runs affected tests only From 7699bafaad2614f43d43e075f98e57aa953ebdd5 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 14:30:23 -0800 Subject: [PATCH 6/8] docs: add code quality commands to README Add Code Quality section documenting typecheck, lint, lint:fix, format, and format:check scripts. Fix unit tests to match IFSEntry metadata nesting structure. --- README.md | 8 ++ apps/backend/services/flowsheet.service.ts | 6 +- tests/unit/services/flowsheet.service.test.ts | 73 ++++++++++++++----- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2f0df60..e4dc4d0 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,14 @@ The dev experience makes extensive use of Node.js project scripts. Here's a rund - `npm run drizzle:migrate` : Applies the generated migrations to the database specified by the environment variables `DB_HOST`, `DB_NAME`, and `DB_PORT`. It also requires `DB_USERNAME` and `DB_PASSWORD`. - `npm run drizzle:drop` : Deletes a given migration file from the migrations directory and removes it from the drizzle cache. +#### Code Quality + +- `npm run typecheck` : Runs `tsc --noEmit` across all workspaces to verify type safety without emitting files. +- `npm run lint` : Runs ESLint with TypeScript type-checked rules and security analysis. +- `npm run lint:fix` : Runs ESLint with auto-fix enabled. +- `npm run format` : Formats all files with Prettier. +- `npm run format:check` : Verifies all files match Prettier formatting (used in CI). + #### Environment Variables Here is an example environment variable file. Create a file with these contents named `.env` in the root of your locally cloned project to ensure your dev environment works properly. diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index 2170730..f84cb26 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -683,8 +683,10 @@ export const transformToV2 = (entry: IFSEntry): Record => { message: entry.message, }; - default: + default: { // Fallback for unknown types - return all fields - return { ...baseFields, ...entry.metadata } as Record; + const { metadata, ...rest } = entry; + return { ...rest, ...metadata } as Record; + } } }; diff --git a/tests/unit/services/flowsheet.service.test.ts b/tests/unit/services/flowsheet.service.test.ts index 10f3602..7812ea6 100644 --- a/tests/unit/services/flowsheet.service.test.ts +++ b/tests/unit/services/flowsheet.service.test.ts @@ -1,22 +1,9 @@ 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, +import { IFSEntryMetadata } from '../../../apps/backend/controllers/flowsheet.controller'; + +const defaultMetadata: IFSEntryMetadata = { artwork_url: null, discogs_url: null, release_year: null, @@ -27,8 +14,58 @@ const createBaseEntry = (overrides: Partial = {}): IFSEntry => ({ soundcloud_url: null, artist_bio: null, artist_wikipedia_url: null, - ...overrides, -}); +}; + +// Helper to create a base entry with common fields +const createBaseEntry = (overrides: Partial = {}): IFSEntry => { + const { + artwork_url, + discogs_url, + release_year, + spotify_url, + apple_music_url, + youtube_music_url, + bandcamp_url, + soundcloud_url, + artist_bio, + artist_wikipedia_url, + metadata: metadataOverride, + ...rest + } = overrides; + + const metadata: IFSEntryMetadata = metadataOverride ?? { + ...defaultMetadata, + ...(artwork_url !== undefined && { artwork_url }), + ...(discogs_url !== undefined && { discogs_url }), + ...(release_year !== undefined && { release_year }), + ...(spotify_url !== undefined && { spotify_url }), + ...(apple_music_url !== undefined && { apple_music_url }), + ...(youtube_music_url !== undefined && { youtube_music_url }), + ...(bandcamp_url !== undefined && { bandcamp_url }), + ...(soundcloud_url !== undefined && { soundcloud_url }), + ...(artist_bio !== undefined && { artist_bio }), + ...(artist_wikipedia_url !== undefined && { artist_wikipedia_url }), + }; + + return { + 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, + metadata, + ...rest, + }; +}; describe('flowsheet.service', () => { describe('transformToV2', () => { From dc7f6ec8ae4880dba1db3355e8c28b94acd11264 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 15:06:55 -0800 Subject: [PATCH 7/8] fix: handle missing metadata in transformToV2 Use optional chaining when accessing entry.metadata since getPlaylist returns raw FSEntry objects without metadata joins. This fixes a 500 error on the V2 playlist endpoint. --- apps/backend/services/flowsheet.service.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index f84cb26..6bae271 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -603,16 +603,16 @@ export const transformToV2 = (entry: IFSEntry): Record => { record_label: entry.record_label, request_flag: entry.request_flag, rotation_play_freq: entry.rotation_play_freq, - artwork_url: entry.metadata.artwork_url, - discogs_url: entry.metadata.discogs_url, - release_year: entry.metadata.release_year, - spotify_url: entry.metadata.spotify_url, - apple_music_url: entry.metadata.apple_music_url, - youtube_music_url: entry.metadata.youtube_music_url, - bandcamp_url: entry.metadata.bandcamp_url, - soundcloud_url: entry.metadata.soundcloud_url, - artist_bio: entry.metadata.artist_bio, - artist_wikipedia_url: entry.metadata.artist_wikipedia_url, + artwork_url: entry.metadata?.artwork_url ?? null, + discogs_url: entry.metadata?.discogs_url ?? null, + release_year: entry.metadata?.release_year ?? null, + spotify_url: entry.metadata?.spotify_url ?? null, + apple_music_url: entry.metadata?.apple_music_url ?? null, + youtube_music_url: entry.metadata?.youtube_music_url ?? null, + bandcamp_url: entry.metadata?.bandcamp_url ?? null, + soundcloud_url: entry.metadata?.soundcloud_url ?? null, + artist_bio: entry.metadata?.artist_bio ?? null, + artist_wikipedia_url: entry.metadata?.artist_wikipedia_url ?? null, }; case 'show_start': From 128616f0ec793494e42fdf547c125dbdd89eb901 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Thu, 12 Feb 2026 18:50:56 -0800 Subject: [PATCH 8/8] style: format files added on main since Prettier branch diverged --- shared/authentication/src/auth.definition.ts | 4 ++-- shared/authentication/src/url-rewrite.ts | 4 +--- tests/unit/authentication/url-rewrite.test.ts | 14 ++++---------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/shared/authentication/src/auth.definition.ts b/shared/authentication/src/auth.definition.ts index fd3aa78..11f555c 100644 --- a/shared/authentication/src/auth.definition.ts +++ b/shared/authentication/src/auth.definition.ts @@ -10,7 +10,7 @@ import { rewriteUrlForFrontend } from './url-rewrite'; const buildResetUrl = (url: string, redirectTo?: string) => { const rewrittenUrl = rewriteUrlForFrontend(url); - + if (!redirectTo) { return rewrittenUrl; } @@ -73,7 +73,7 @@ export const auth: Auth = betterAuth({ emailVerification: { sendVerificationEmail: async ({ user, url }, request) => { const verificationUrl = rewriteUrlForFrontend(url); - + void sendVerificationEmailMessage({ to: user.email, verificationUrl, diff --git a/shared/authentication/src/url-rewrite.ts b/shared/authentication/src/url-rewrite.ts index ddc228d..dd57243 100644 --- a/shared/authentication/src/url-rewrite.ts +++ b/shared/authentication/src/url-rewrite.ts @@ -5,9 +5,7 @@ export const rewriteUrlForFrontend = (url: string): string => { try { const parsed = new URL(url); - const frontend = new URL( - process.env.FRONTEND_SOURCE || 'http://localhost:3000' - ); + const frontend = new URL(process.env.FRONTEND_SOURCE || 'http://localhost:3000'); parsed.host = frontend.host; parsed.protocol = frontend.protocol; return parsed.toString(); diff --git a/tests/unit/authentication/url-rewrite.test.ts b/tests/unit/authentication/url-rewrite.test.ts index b82b677..072a634 100644 --- a/tests/unit/authentication/url-rewrite.test.ts +++ b/tests/unit/authentication/url-rewrite.test.ts @@ -13,13 +13,10 @@ describe('rewriteUrlForFrontend', () => { it('replaces host and protocol while preserving path and query params', () => { process.env.FRONTEND_SOURCE = 'https://dj.wxyc.org'; - const input = - 'https://api.wxyc.org/auth/verify-email?token=abc123&callbackURL=%2Fonboarding'; + const input = 'https://api.wxyc.org/auth/verify-email?token=abc123&callbackURL=%2Fonboarding'; const result = rewriteUrlForFrontend(input); - expect(result).toBe( - 'https://dj.wxyc.org/auth/verify-email?token=abc123&callbackURL=%2Fonboarding' - ); + expect(result).toBe('https://dj.wxyc.org/auth/verify-email?token=abc123&callbackURL=%2Fonboarding'); }); it('handles URLs without query params', () => { @@ -32,13 +29,10 @@ describe('rewriteUrlForFrontend', () => { it('preserves complex query parameters', () => { process.env.FRONTEND_SOURCE = 'https://dj.wxyc.org'; - const input = - 'https://api.wxyc.org/auth/verify-email?token=xyz&callbackURL=%2Fdashboard&redirectTo=%2Fhome'; + const input = 'https://api.wxyc.org/auth/verify-email?token=xyz&callbackURL=%2Fdashboard&redirectTo=%2Fhome'; const result = rewriteUrlForFrontend(input); - expect(result).toBe( - 'https://dj.wxyc.org/auth/verify-email?token=xyz&callbackURL=%2Fdashboard&redirectTo=%2Fhome' - ); + expect(result).toBe('https://dj.wxyc.org/auth/verify-email?token=xyz&callbackURL=%2Fdashboard&redirectTo=%2Fhome'); }); it('handles invalid URLs gracefully by returning original', () => {